mcaskill/charcoal-model-collection

Model collection and repository classes for Charcoal.

1.3.0 2020-07-13 21:34 UTC

This package is auto-updated.

Last update: 2020-07-13 21:36:35 UTC


README

License Latest Stable Version Build Status

Support package providing advanced model collections and collection loaders for Charcoal projects.

Installation

composer require mcaskill/charcoal-model-collection

See composer.json for depenencides.

Collections

1. Charcoal\Support\Model\Collection\Collection

Provides methods to manipulate the collection or retrieve specific models.

filter()

Filter the collection of objects using the given callback.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [
//     1 => Model (active: 1), 2 => Model (active: 0),
//     3 => Model (active: 1), 4 => Model (active: 1),
//     5 => Model (active: 0)
// ]

$filtered = $collection->filter(function ($obj, $id) {
    return ($obj['active'] === true);
});
// [ 1 => Model, 3 => Model, 4 => Model ]

forPage()

"Paginate" the collection by slicing it into a smaller collection.

$collection = new Collection([ $a, $b, $c, $d, $e, $f, $g, $h, $i, $j ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model,… ]

$chunk = $collection->forPage(2, 3);
// [ 4 => Model, 5 => Model, 6 => Model ]

only()

Extract the objects with the specified keys.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

$filtered = $collection->only(2);
// [ 2 => Model ]

$filtered = $collection->only([ 1, 3 ]);
// [ 1 => Model, 3 => Model ]

$filtered = $collection->only(2, 4);
// [ 2 => Model, 4 => Model ]

pop()

Remove and return the last object from the collection.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

$collection->pop();
// Model (5)

$collection->toArray();
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model ]

prepend()

Add an object onto the beginning of the collection.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

$collection->prepend($o);
// Model (15)

$filtered->toArray();
// [ 15 => Model, 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

random()

Retrieve one or more random objects from the collection.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

$collection->random();
// Model (3)

$collection->random(2);
// [ 1 => Model, 3 => Model ]

reverse()

Reverse the order of objects in the collection.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

$reversed = $collection->reverse();
// [ 5 => Model, 4 => Model, 3 => Model, 2 => Model, 1 => Model ]

shift()

Remove and return the first object from the collection.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

$collection->shift();
// Model (1)

$collection->toArray();
// [ 2 => Model, 3 => Model, 4 => Model, 5 => Model ]

slice()

Extract a slice of the collection.

$collection = new Collection([ $a, $b, $c, $d, $e, $f, $g, $h, $i, $j ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model,… ]

$slice = $collection->slice(4);
// [ 5 => Model, 6 => Model, 7 => Model, 8 => Model, 9 => Model, 10 => Model ]

$slice = $collection->slice(4, 2);
// [ 5 => Model, 6 => Model ]

sortBy()

Sort the collection by the given callback or object property.

$collection = new Collection([ $a, $b, $c ]);
// [ 1 => Model (position: 5), 2 => Model (position: 2), 3 => Model (position: 0) ]

$sorted = $collection->sortBy('position');
// [ 3 => Model, 2 => Model, 1 => Model ]

sortByDesc()

Sort the collection in descending order using the given callback or object property.

take()

Extract a portion of the first or last objects from the collection.

$collection = new Collection([ $a, $b, $c, $d, $e, $f ]);
// [ 1 => Model, 2 => Model, 3 => Model, 4 => Model, 5 => Model, 6 => Model ]

$chunk = $collection->take(3);
// [ 1 => Model, 2 => Model, 3 => Model ]

$chunk = $collection->take(-2);
// [ 5 => Model, 6 => Model ]

where()

Filter the collection of objects by the given key/value pair.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [
//     1 => Model (active: 1), 2 => Model (active: 0),
//     3 => Model (active: 1), 4 => Model (active: 1),
//     5 => Model (active: 0)
// ]

$filtered = $collection->where('active', true);
// [ 1 => Model, 3 => Model, 4 => Model ]

whereIn()

Filter the collection of objects by the given key/value pair.

$collection = new Collection([ $a, $b, $c, $d, $e ]);
// [
//     1 => Model (name: "Lorem"), 2 => Model (name: "Ipsum"),
//     3 => Model (name: "Dolor"), 4 => Model (name: "Elit"),
//     5 => Model (name: "Amet")
// ]

$filtered = $collection->whereIn('name', [ 'Amet', 'Dolor' ]);
// [ 3 => Model, 5 => Model ]

Repositories

1. Charcoal\Support\Model\Repository\CollectionLoaderIterator

Provides improved counting of found rows (via SQL_CALC_FOUND_ROWS), supports PHP Generators via "cursor" methods, and supports chaining the loader directly into an iterator construct or appending additional criteria.

1.1. Lazy Collections

The CollectionLoaderIterator leverages PHP's generators to allow you to work with large collections while keeping memory usage low.

When using the traditional load methods, all models must be loaded into memory at the same time.

$repository = (new CollectionLoaderIterator)->setModel(Post::class);

$repository->addFilter('active', true)->addFilter('published <= NOW()');

$posts = $repository->load();
// query: SELECT …
// array: Post, Post, Post,…

foreach ($posts as $post) {
    echo $post['title'];
}

However, the cursor methods return Generator objects instead. This allows you to keep one model loaded in memory at a time:

$repository = (new CollectionLoaderIterator)->setModel(Post::class);

$repository->addFilter('active', true)->addFilter('published <= NOW()');

$posts = $repository->cursor();
// Generator

foreach ($posts as $post) { // query: SELECT …
    // Post
    echo $post['title'];
}

1.2. IteratorAggregate

The CollectionLoaderIterator implements IteratorAggregate which allows the repository to be used in a foreach construct without the need to explicitly call a query method.

class Post extends AbstractModel
{
    /**
     * @return Comment[]|CollectionLoaderIterator
     */
    public function getComments() : iterable
    {
        $comments = (new CollectionLoaderIterator)->setModel(Comment::class);

        $byPost = [
            'property' => 'post_id',
            'value'    => $this['id'],
        ];

        return $comments->addFilter($byPost);
    }
}

Internally, the IteratorAggregate::getIterator() method calls the CollectionLoaderIterator::cursor() method which in turn returns a Generator object.

$post = $factory->create(Post::class)->load(1);

foreach ($post['comments'] as $comment) { // query: SELECT …
    // Comment
}

Furthermore, you can continue to chain constraints onto the repository:

$post = $factory->create(Post::class)->load(1);

$comments = $post['comments']->addFilter('approved', true);
// CollectionLoaderIterator

foreach ($comments as $comment) { // query: SELECT …
    // Comment
}

1.3. SQL_CALC_FOUND_ROWS

If the SQL_CALC_FOUND_ROWS option is included in a SELECT statement, the FOUND_ROWS() function will be invoked afterwards to retrieve the number of objects the statement would have returned without the LIMIT.

Using the query builder interface, the generated statement will include SQL_CALC_FOUND_ROWS option unless the query is targeting a single object.

$repository = (new CollectionLoaderIterator)->setModel(Post::class);

$repository->addFilter('active', true)
           ->addFilter('published <= NOW()')
           ->setNumPerPage(10)
           ->setPage(3);

// Automatically find total count from query builder
$posts = $repository->load();
// query: SELECT SQL_CALC_FOUND_ROWS * FROM `charcoal_users` WHERE ((`active` = '1') AND (`published` <= NOW())) LIMIT 30, 10;
// query: SELECT FOUND_ROWS();
$total = $repository->foundObjs();
// int: 38

// Automatically find total count from query
$users = $repository->reset()->loadFromQuery('SELECT SQL_CALC_FOUND_ROWS * … LIMIT 0, 20');
// query: SELECT SQL_CALC_FOUND_ROWS * … LIMIT 0, 20;
// query: SELECT FOUND_ROWS();
$total = $repository->foundObjs();
// int: 38

// Automatically find total count from query
$users = $repository->reset()->loadFromQuery('SELECT * … LIMIT 0, 20');
// query: SELECT * … LIMIT 0, 20;
$total = $repository->foundObjs();
// LogicException: Can not count found objects for the last query

2. Charcoal\Support\Model\Repository\ModelCollectionLoader

Provides support for cloning, preventing model swapping, and sharing the same data source.

2.1. Model Protection

Once a model is assigned to the ModelCollectionLoader, any attempts to replace it will result in a thrown exception:

$repository = (new ModelCollectionLoader)->setModel(Post::class);

// …

$repository->setModel(Comment::class);
// RuntimeException: A model is already assigned to this collection loader: \App\Model\Post

On its own, this feature is not very practical but in concert with the ScopedCollectionLoader this becomes an important safety measure.

2.2. Collection Loader Cloning

When cloning the ModelCollectionLoader via the clone keyword or the cloneWith() method, the model protection mechanism will be unlocked until a new object type is assigned or until the source() method is called.

$postsLoader    = (new ModelCollectionLoader)->setModel(Post::class);
$commentsLoader = (clone $postsLoader)->setModel(Comment::class);
$postsLoader    = (new ModelCollectionLoader)->setModel(Post::class);
$commentsLoader = $postsLoader->cloneWith(Comment::class);
$tagsLoader     = $postsLoader->cloneWith([
    'model'      => Tag::class,
    'collection' => 'array',
]);

2.3. Source Sharing

A Charcoal Model is based on the ActiveRecord implementation for working with data sources; which is to say a Model allows you to interact with data in your database. This interaction is facilitated by a "Data Source" interface, like the DatabaseSource class. Each instance of a Model will usually create its own instance of a Data Source object; in other words, you end up always working with two objects per Model (the Model and the Data Source).

To reduce the number of objects in a request's lifecycle, its a good practice to assign a single instance of a Data Source to all Models. When the ModelCollectionLoader creates a new instance of the Model being queried, it will assign the prototype Model's Data Source object (the one that is queried upon by the repository).

$posts = (new BaseCollectionLoader)->setModel(Post::class)->load();
// array: Post, Post, Post,…

($posts[0]->source() === $posts[2]->source())
// bool: false

$posts = (new ModelCollectionLoader)->setModel(Post::class)->load();
// array: Post, Post, Post,…

($posts[0]->source() === $posts[2]->source())
// bool: true

3. Charcoal\Support\Model\Repository\ScopedCollectionLoader

Provides support for default filters, orders, and pagination, which are automatically applied upon the loader's creation and after every reset.

$repository = new ScopedCollectionLoader([
    'logger'          => $container['logger'],
    'factory'         => $container['model/factory'],
    'model'           => Post::class,
    'default_filters' => [
        [
            'property' => 'active',
            'value'    => true,
        ],
        [
            'property' => 'publish_date',
            'operator' => 'IS NOT NULL',
        ],
    ],
    'default_orders'  => [
        [
            'property'  => 'publish_date',
            'direction' => 'desc',
        ],
    ],
    'default_pagination' => [
        'num_per_page' => 20,
    ],
]);

$posts = $repository->addFilter('publish_date <= NOW()')->load();
// query: SELECT SQL_CALC_FOUND_ROWS * FROM `posts` WHERE ((`active` = '1') AND (`publish_date` IS NOT NULL) AND (`published` <= NOW())) ORDER BY `publish_date` DESC LIMIT 20;

$repository->reset()->load();
// query: SELECT SQL_CALC_FOUND_ROWS * FROM `posts` WHERE ((`active` = '1') AND (`publish_date` IS NOT NULL)) ORDER BY `publish_date` DESC LIMIT 20;

If you would like to disable the default criteria on a repository, you may use the withoutDefaults method. The method accepts a callback to interact with collection loader if, for example, you only wish to apply default orders:

$repository = new ScopedCollectionLoader([…]);

$posts = $repository->withoutDefaults(function () {
    $this->applyDefaultOrders();
    $this->applyDefaultPagination();
})->load();
// query: SELECT SQL_CALC_FOUND_ROWS * FROM `posts` ORDER BY `publish_date` DESC LIMIT 20;

4. Charcoal\Support\Model\Repository\CachedCollectionLoader

Provides support for storing the data of loaded models in a cache pool, similar to the \Charcoal\Model\Service\ModelLoader and using the same cache key for interoperability.

$repository = new CachedCollectionLoader([
    'cache'   => $container['cache'],
    'logger'  => $container['logger'],
    'factory' => $container['model/factory'],
    'model'   => Post::class,
]);

If you would like to disable the caching process on a repository, you may use the withoutCache method. The method accepts a callback to interact with collection loader:

$repository = new CachedCollectionLoader([…]);

$posts = $repository->withoutCache()->cursor();
// Generator

License

  • Charcoal Model Collections and Repositories component is licensed under the MIT license. See LICENSE for details.
  • Charcoal framework is licensed under the MIT license. See LICENSE for details.