diff --git a/.gitignore b/.gitignore index 2151ca5..a00c3c1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ composer.lock .phpunit.result.cache .DS_Store .idea* +.php-cs-fixer.cache diff --git a/README.md b/README.md index 99ed594..ab762db 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Eloquence is a package to extend Laravel's base Eloquent models and functionality. -It provides a number of utilities and classes to work with Eloquent in new and useful ways, -such as camel cased attributes (for JSON apis), count caching, uuids and more. +It provides a number of utilities and attributes to work with Eloquent in new and useful ways, +such as camel cased attributes (such as for JSON apis and code style cohesion), data aggregation and more. ## Installation @@ -17,251 +17,260 @@ Install the package via composer: ## Usage -First, add the eloquence service provider to your config/app.php file: +Eloquence is automatically discoverable by Laravel, and shouldn't require any further steps. For those on earlier +versions of Laravel, you can add the package as per normal in your config/app.php file: 'Eloquence\EloquenceServiceProvider', -It's important to note that this will automatically re-bind the Model class -that Eloquent uses for many-to-many relationships. This is necessary because -when the Pivot model is instantiated, we need it to utilise the parent model's -information and traits that may be needed. - -You should now be good to go with your models. +The service provider doesn't do much, other than enable the query log, if configured. ## Camel case all the things! -For those of us who prefer to work with a single coding standard right across our applications, -using the CamelCaseModel trait will ensure that all those attributes, relationships and associated -data from our Eloquent models persist through to our APIs in a camel-case manner. This is important -if you are writing front-end applications, which are also using camelCase. This allows for a -better standard across our application. To use: - - use \Eloquence\Behaviours\CamelCasing; +For those of us who prefer to work with a single coding style right across our applications, using the CamelCased trait +will ensure you can do exactly that. It transforms all attribute access from camelCase to snake_case in real-time, +providing a unified coding style across your application. This means everything from attribute access to JSON API +responses will all be camelCased. To use, simply add the CamelCased trait to your model: -Put the above line in your models and that's it. + use \Eloquence\Behaviours\HasCamelCasing; ### Note! -Eloquence DOES NOT CHANGE how you write your schema migrations. You should still be using snake_case -when setting up your fields and tables in your database schema migrations. This is a good thing - -snake_case of field names is the defacto standard within the Laravel community :) - - -## UUIDs - -Eloquence comes bundled with UUID capabilities that you can use in your models. - -Simply include the Uuid trait: - - use Eloquence\Behaviours\Uuid; - -And then disable auto incrementing ids: - - public $incrementing = false; - -This will turn off id auto-incrementing in your model, and instead automatically generate a UUID4 value for your id field. One -benefit of this is that you can actually know the id of your record BEFORE it's saved! - -You must ensure that your id column is setup to handle UUID values. This can be done by creating a migration with the following -properties: - - $table->char('id', $length = 36)->index(); - -It's important to note that you should do your research before using UUID functionality and whether it works for you. UUID -field searches are much slower than indexed integer fields (such as autoincrement id fields). - - -### Custom UUIDs - -Should you need a custom UUID solution (aka, maybe you don't want to use a UUID4 id), you can simply define the value you wish on -the id field. The UUID model trait will not set the id if it has already been defined. In this use-case however, it's probably no good -to use the Uuid trait, as it's practically useless in this scenario. +Eloquence ***DOES NOT CHANGE*** how you write your schema migrations. You should still be using snake_case when setting +up your columns and tables in your database schema migrations. This is a good thing - snake_case of columns names is the +defacto standard within the Laravel community and is widely-used across database schemas, as well. ## Behaviours -Eloquence comes with a system for setting up behaviours, which are really just small libraries that you can use with your Eloquent models. -The first of these is the count cache. +Eloquence comes with a system for setting up behaviours, which are really just small libraries that you can use with your +Eloquent models. The first of these is the count cache. ### Count cache -Count caching is where you cache the result of a count of a related table's records. A simple example of this is where you have a user who -has many posts. In this example, you may want to count the number of posts a user has regularly - and perhaps even order by this. In SQL, -ordering by a counted field is slow and unable to be indexed. You can get around this by caching the count of the posts the user -has created on the user's record. +Count caching is where you cache the result of a count on a related model's record. A simple example of this is where you +have posts that belong to authors. In this situation, you may want to count the number of posts an author has regularly, +and perhaps even order by this count. In SQL, ordering by an aggregated value is unable to be indexed and therefore - slow. +You can get around this by caching the count of the posts the author has created on the author's model record. To get this working, you need to do two steps: -1. Use the Countable trait on the model and -2. Configure the count cache settings +1. Use the HasCounts trait on the child model (in this, case Post) and +2. Configure the count cache settings by using the CountedBy attribute. -#### Configure the count cache +#### Configuring a count cache -To setup the count cache configuration, we need to have the model use Countable trait, like so: +To setup a count cache configuration, we add the HasCounts trait, and setup the CountedBy attribute: ```php -class Post extends Eloquent { - use Countable; +use Eloquence\Behaviours\CountCache\CountedBy; +use Eloquence\Behaviours\CountCache\HasCounts; +use Illuminate\Database\Eloquent\Model; - public function countCaches() { - return [User::class]; +class Post extends Model { + use HasCounts; + + #[CountedBy] + public function author(): BelongsTo + { + return $this->belongsTo(Author::class); } } ``` -This tells the count cache that the Post model has a count cache on the User model. So, whenever a post is added, or modified or -deleted, the count cache behaviour will update the appropriate user's count cache for their posts. In this case, it would update `post_count` -on the user model. +This tells the count cache behaviour that the model has an aggregate count cache on the Author model. So, whenever a post +is added, modified or deleted, the count cache behaviour will update the appropriate author's count cache for their +posts. In this case, it would update `post_count` field on the author model. The example above uses the following standard conventions: * `post_count` is a defined field on the User model table -* `user_id` is the field representing the foreign key on the post model -* `id` is the primary key on the user model table -These are, however, configurable: +It uses your own relationship to find the related record, so no other configuration is required! + +Of course, if you have a different setup, or different field names, you can alter the count cache behaviour by defining +the appropriate field to update: ```php -class Post extends Eloquent { - use Countable; +class Post extends Model { + use HasCounts; - public function countCaches() { - return [ - 'num_posts' => ['User', 'users_id', 'id'] - ]; + #[CountedBy(as: 'total_posts')] + public function author(): BelongsTo + { + return $this->belongsTo(Author::class); } } ``` -This example customises the count cache field, and the related foreign key, with `num_posts` and `users_id`, respectively. +When setting the as: value (using named parameters here from PHP 8.0 for illustrative and readability purposes), you're +telling the count cache that the aggregate field on the Author model is actually called `total_posts`. -Alternatively, you can be very explicit about the configuration (useful if you are using count caching on several tables -and use the same column name on each of them): +HasCounts is not limited to just one count cache configuration. You can define as many as you need for each BelongsTo +relationship, like so: ```php -class Post extends { - use Countable; +#[CountedBy(as: 'total_posts')] +public function author(): BelongsTo +{ + return $this->belongsTo(Author::class); +} - public function countCaches() { - return [ - [ - 'model' => 'User', - 'field' => 'num_posts', - 'foreignKey' => 'users_id', - 'key' => 'id' - ] - ]; - } +#[CountedBy(as: 'num_posts')] +public function category(): BelongsTo +{ + return $this->belongsTo(Category::class); } ``` -If using the explicit configuration, at a minimum you will need to define the "model" parameter. The "countField", "foreignKey", -and "key" parameters will be calculated using the standard conventions mentioned above if they are omitted. - -With this configuration now setup - you're ready to go! - - ### Sum cache -Sum caching is similar to count caching, except that instead of caching a _count_ of a related table's records, you cache a _sum_ -of a particular field on the related table's records. A simple example of this is where you have an order that has many items. -Using sum caching, you can cache the sum of all the items' prices, and store that sum in the order table. +Sum caching is similar to count caching, except that instead of caching a _count_ of the related model objects, you cache a _sum_ +of a particular field on the child model's object. A simple example of this is where you have an order that has many items. +Using sum caching, you can cache the sum of all the items' prices, and store that as a cached sum on the Order model. To get this working -- just like count caching -- you need to do two steps: -1. Utilise the Summable trait on the model and -2. Configure the model for any sum caches +1. Add the HasSums to your child model and +2. Add SummedBy attribute to each relationship method that requires it. #### Configure the sum cache To setup the sum cache configuration, simply do the following: ```php -class Item extends Eloquent { - use Summable; +use Eloquence\Behaviours\SumCache\HasSums; +use Eloquence\Behaviours\SumCache\SummedBy; +use Illuminate\Database\Eloquent\Model; - public function sumCaches() { - return [Order::class]; +class Item extends Model { + use HasSums; + + #[SummedBy(from: 'amount', as: 'total_amount')] + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); } } ``` -This tells the sum cache manager that the Item model has a sum cache on the Order model. So, whenever an item is added, modified, or -deleted, the sum cache behaviour will update the appropriate order's sum cache for their items. In this case, it would update `item_total` -on the Order model. +Unlike the count cache which can assume sensible defaults, the sum cache needs a bit more guidance. The example above +tells the sum cache that there is an `amount` field on Item that needs to be summed to the `total_amount` field on Order. -The example above uses the following conventions: +### Cache recommendations -* `item_total` is a defined field on the Order model table -* `total` is a defined field on the Item model table (the column we are summing) -* `order_id` is the field representing the foreign key on the item model -* `id` is the primary key on the order model table +Because the cache system works directly with other model objects and requires multiple writes to the database, it is +strongly recommended that you wrap your model saves that utilise caches in a transaction. In databases like Postgres, +this is automatic, but for databases like MySQL you need to make sure you're using a transactional database engine +like InnoDB. -These are, however, configurable: +The reason for needing transactions is that if any one of your queries fail, your caches will end up out of sync. It's +better for the entire operation to fail, than to have this happen. Below is an example of using a database transaction +using Laravel's DB facade: ```php -class Item extends Eloquent { - use Summable; - - public function sumCaches() { - return [ - 'item_total' => ['Order', 'total', 'order_id', 'id'] - ]; - } -} +DB::transaction(function() { + $post = new Post; + $post->authorId = $author->id; + $post->save(); +}); ``` -Or using the verbose syntax: - -```php -class Item extends Eloquent { - use Summable; - - public function sumCaches() { - return [ - [ - 'model' => 'Order', - 'columnToSum' => 'total', - 'field' => 'item_total' - 'foreignKey' => 'order_id', - 'key' => 'id' - ] - ]; - } -} -``` +If we return to the example above with posts having authors - if this save was not wrapped in a transaction, and the post +was created but for some reason the database failed immediately after, you would never see the count cache update in the +parent Author model, you'll end up with erroneous data that can be quite difficult to debug. -Both of these examples implements the default settings. - -With these settings configured, you will now see the related model's sum cache updated every time an item is added, updated, or removed. - -### Sluggable models +### Sluggable Sluggable is another behaviour that allows for the easy addition of model slugs. To use, implement the Sluggable trait: ```php -class User extends Eloquent { - use Sluggable; +class User extends Model { + use HasSlugs; - public function slugStrategy() { + public function slugStrategy(): string + { return 'username'; } } ``` In the example above, a slug will be created based on the username field of the User model. There are two other -slugs that are supported however, as well: +slugs that are supported, as well: * id and * uuid -The only difference between the two above, is that if you're using UUIDs, the slug will be generated previous -to the save, based on the uuid field. With ids, which are generally auto-increase strategies - the slug has -to be generated after the record has been saved - which results in a secondary save call to the database. +The only difference between the two above, is that if you're using UUIDs, the slug will be generated prior to the model +being saved, based on the uuid field. With ids, which are generally auto-increase strategies - the slug has to be +generated after the record has been saved - which results in a secondary save call to the database. That's it! Easy huh? +# Upgrading from v10 +Version 11 of Eloquence is a complete rebuild and departure from the original codebase, utilising instead PHP 8.1 attributes +and moving away from traits/class extensions where possible. This means that in some projects many updates will need to +be made to ensure that your use of Eloquence continues to work. + +## 1. Class renames + +* Camelcasing has been renamed to HasCamelCasing +* Sluggable renamed to HasSlugs + +## 2. Updates to how caches work +All your cache implementations will need to be modified following the guide above. But in short, you'll need to import +and apply the provided attributes to the relationship methods on your models that require aggregated cache values. + +The best part about the new architecture with Eloquence, is that you can define your relationships however you want! If +you have custom where clauses or other conditions that restrict the relationship, Eloquence will respect that. This makes +Eloquence now considerably more powerful and supportive of individual domain requirements than ever before. + +Let's use a real case. This is the old approach, using Countable as an example: + +```php +class Post extends Model +{ + use Countable; + + public function countCaches() { + return [ + 'num_posts' => ['User', 'users_id', 'id'] + ]; + } +} +``` + +To migrate that to v11, we would do the following: + +```php +use Eloquence\Behaviours\CountCache\CountedBy; + +class Post extends Model +{ + use \Eloquence\Behaviours\CountCache\HasCounts; + + #[CountedBy(as: 'num_posts')] + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} +``` + +Note the distinct lack of required configuration. The same applies to the sum behaviour - simply migrate your configuration +away from the cache functions, and into the attributes above the relationships you wish to have an aggregated cache +value for. + ## Changelog +#### 11.0.0 + +* Complete rework of the Eloquent library - version 11 is **_not_** backwards-compatible +* UUID support removed - both UUIDs and ULIDs are now natively supported in Laravel and have been for some time +* Cache system now works directly with models and their relationships, allowing for fine-grained control over the models it works with +* Console commands removed - model caches can be rebuilt using Model::rebuildCache() if something goes awry +* Fixed a number of bugs across both count and sum caches +* CamelCasing renamed to CamelCased +* Syntax, styling, and standards all modernised + #### 10.0.0 * Boost in version number to match Laravel diff --git a/composer.json b/composer.json index b53384a..76d3c6e 100644 --- a/composer.json +++ b/composer.json @@ -2,13 +2,17 @@ "name": "kirkbushell/eloquence", "description": "A set of extensions adding additional functionality and consistency to Laravel's awesome Eloquent library.", "keywords": [ - "laravel", - "eloquent", + "aggregates", + "cache", "camelcase", "camel", "case", + "count", + "eloquent", + "laravel", "snake_case", - "snake" + "snake", + "sum" ], "authors": [ { @@ -21,13 +25,15 @@ "hashids/hashids": "^4.1", "illuminate/database": "^10.0", "illuminate/support": "^10.0", - "ramsey/uuid": "^4.7" + "hanneskod/classtools": "^0.1.0", + "symfony/finder": "^6.3" }, "require-dev": { "illuminate/events": "^10.0", "mockery/mockery": "^1.4.4", "orchestra/testbench": "^8.0", - "phpunit/phpunit": "^9.5.10" + "phpunit/phpunit": "^9.5.10", + "friendsofphp/php-cs-fixer": "^3.48" }, "autoload": { "psr-4": { @@ -36,8 +42,38 @@ } }, "scripts": { - "test": "phpunit --colors=always" + "test": "phpunit --colors=always", + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "@build", + "@php vendor/bin/testbench serve" + ] }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "extra": { + "laravel": { + "providers": [ + "Eloquence\\EloquenceServiceProvider" + ] + } + }, + "autoload-dev": { + "psr-4": { + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" + } + }, + "config": { + "allow-plugins": { + "bamarni/composer-bin-plugin": true + } + } } diff --git a/config/eloquence.php b/config/eloquence.php new file mode 100644 index 0000000..ca810ff --- /dev/null +++ b/config/eloquence.php @@ -0,0 +1,8 @@ + [ + 'enabled' => env('ELOQUENCE_LOGGING_ENABLED', false), + 'driver' => env('ELOQUENCE_LOGGING_DRIVER', env('LOG_CHANNEL', 'stack')), + ] +]; diff --git a/phpunit.xml b/phpunit.xml index a80d0e1..92e20c7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,4 +17,7 @@ ./tests/Acceptance + + + diff --git a/src/Behaviours/CacheConfig.php b/src/Behaviours/CacheConfig.php new file mode 100644 index 0000000..e84c2eb --- /dev/null +++ b/src/Behaviours/CacheConfig.php @@ -0,0 +1,52 @@ +{$this->relationName}(); + } + + /** + * Returns the current related model. + */ + public function relatedModel(Model $model): Model + { + return $model->{$this->relationName}; + } + + /** + * Returns -a- related model object - this object is actually empty, and is found on the query builder, used to + * infer certain information abut the relationship that cannot be found on CacheConfig::relation. + */ + public function emptyRelatedModel(Model $model): Model + { + return $this->relation($model)->getModel(); + } + + /** + * Returns the related model class name. + */ + public function relatedModelClass($model): string + { + return get_class($this->emptyRelatedModel($model)); + } + + public function foreignKeyName(Model $model): string + { + return $this->relation($model)->getForeignKeyName(); + } +} diff --git a/src/Behaviours/Cacheable.php b/src/Behaviours/Cacheable.php index 792be4d..7debde8 100644 --- a/src/Behaviours/Cacheable.php +++ b/src/Behaviours/Cacheable.php @@ -1,144 +1,109 @@ processConfig($config); - - $sql = DB::table($config['table'])->where($config['key'], $foreignKey); - - /* - * Increment for + operator - */ - if ($operation == '+') { - return $sql->increment($config['field'], $amount); - } - - /* - * Decrement for - operator - */ - return $sql->decrement($config['field'], $amount); - } + abstract private function configuration(): array; /** - * Rebuilds the cache for the records in question. - * - * @param array $config - * @param Model $model - * @param $command - * @param null $aggregateField - * @return mixed + * Helper method for easier use of the implementing classes. */ - public function rebuildCacheRecord(array $config, Model $model, $command, $aggregateField = null) + public static function for(Model $model): self { - $config = $this->processConfig($config); - $table = $this->getModelTable($model); - - if (is_null($aggregateField)) { - $aggregateField = $config['foreignKey']; - } else { - $aggregateField = Str::snake($aggregateField); - } - - $sql = DB::table($table)->select($config['foreignKey'])->groupBy($config['foreignKey']); - - if (strtolower($command) == 'count') { - $aggregate = $sql->count($aggregateField); - } else if (strtolower($command) == 'sum') { - $aggregate = $sql->sum($aggregateField); - } else if (strtolower($command) == 'avg') { - $aggregate = $sql->avg($aggregateField); - } else { - $aggregate = null; - } + return new self($model); + } - return DB::table($config['table']) - ->update([ - $config['field'] => $aggregate - ]); + public function reflect(string $attributeClass, \Closure $fn) + { + $reflect = new ReflectionClass($this->model); + + // This behemoth cycles through all valid methods, and then gets only the attributes we care about, + // formatting it in a way that is usable by our various aggregate service classes. + return collect($reflect->getMethods()) + ->filter(fn (ReflectionMethod $method) => count($method->getAttributes($attributeClass)) > 0) + ->flatten() + ->map(function (ReflectionMethod $method) use ($attributeClass) { + return collect($method->getAttributes($attributeClass))->map(fn (\ReflectionAttribute $attribute) => [ + 'name' => $method->name, + 'attribute' => $attribute->newInstance(), + ])->toArray(); + }) + ->flatten(1) + ->mapWithKeys($fn) + ->toArray(); } /** - * Creates the key based on model properties and rules. - * - * @param string $model - * @param string $field - * - * @return string + * Applies the provided function using the relevant configuration to all configured relations. Configuration + * would be one of countedBy, summedBy, averagedBy.etc. */ - protected function field($model, $field) + protected function apply(Closure $function): void { - $class = strtolower(class_basename($model)); - $field = $class . '_' . $field; - - return $field; + foreach ($this->configuration() as $key => $value) { + $function($this->config($key, $value)); + } } /** - * Process configuration parameters to check key names, fix snake casing, etc.. + * Updates a table's record based on the query information provided in the $config variable. * - * @param array $config - * @return array + * @param string $operation Whether to increase or decrease a value. Valid values: +/- */ - protected function processConfig(array $config) + protected function updateCacheRecord(Model $model, CacheConfig $config, string $operation, int $amount): void { - return [ - 'model' => $config['model'], - 'table' => $this->getModelTable($config['model']), - 'field' => Str::snake($config['field']), - 'key' => Str::snake($this->key($config['key'])), - 'foreignKey' => Str::snake($this->key($config['foreignKey'])), - ]; + $this->updateCacheValue($model, $config, $amount); } /** - * Returns the true key for a given field. + * It's a bit hard to read what's going on in this method, so let's elaborate. * - * @param string $field - * @return mixed + * 1. Get the foreign key of the model that needs to be queried. + * 2. Get the aggregate value for all records with that foreign key. + * 3. Update the related model wth the relevant aggregate value. */ - protected function key($field) + public function rebuildCacheRecord(CacheConfig $config, Model $model, $command): void { - if (method_exists($this->model, 'getTrueKey')) { - return $this->model->getTrueKey($field); - } - - return $field; + $foreignKey = $config->foreignKeyName($model); + $related = $config->emptyRelatedModel($model); + + $updateSql = sprintf( + 'UPDATE %s SET %s = COALESCE((SELECT %s(%s) FROM %s WHERE %s = %s.%s), 0)', + $related->getTable(), + $config->aggregateField, + $command, + $config->sourceField, + $model->getTable(), + $foreignKey, + $related->getTable(), + $related->getKeyName() + ); + + DB::update($updateSql); } /** - * Returns the table for a given model. Model can be an Eloquent model object, or a full namespaced - * class string. - * - * @param string|Model $model - * @return mixed + * Update the cache value for the model. */ - protected function getModelTable($model) + protected function updateCacheValue(Model $model, CacheConfig $config, int $amount): void { - if (!is_object($model)) { - $model = new $model; - } - - return DB::getTablePrefix().$model->getTable(); + $model->{$config->aggregateField} = $model->{$config->aggregateField} + $amount; + $model->save(); } - } diff --git a/src/Behaviours/CountCache/CountCache.php b/src/Behaviours/CountCache/CountCache.php index 8247bd3..db2f617 100644 --- a/src/Behaviours/CountCache/CountCache.php +++ b/src/Behaviours/CountCache/CountCache.php @@ -1,122 +1,82 @@ model = $model; } - /** - * Applies the provided function to the count cache setup/configuration. - * - * @param \Closure $function - */ - public function apply(\Closure $function) + private function configuration(): array { - foreach ($this->model->countCaches() as $key => $cache) { - $function($this->config($key, $cache)); - } + return $this->reflect(CountedBy::class, function (array $config) { + $aggregateField = $config['attribute']->as ?? Str::lower(Str::snake(class_basename($this->model))).'_count'; + return [$config['name'] => $aggregateField]; + }); } /** - * Update the cache for all operations. + * When a model is updated, its foreign keys may have changed. In this situation, we need to update both the original + * related model, and the new one.The original would be deducted the value, whilst the new one is increased. */ - public function update() + public function update(): void { - $this->apply(function ($config) { - $foreignKey = Str::snake($this->key($config['foreignKey'])); + $this->apply(function (CacheConfig $config) { + $foreignKey = $config->foreignKeyName($this->model); - if ($this->model->getOriginal($foreignKey) && $this->model->{$foreignKey} != $this->model->getOriginal($foreignKey)) { - $this->updateCacheRecord($config, '-', 1, $this->model->getOriginal($foreignKey)); - $this->updateCacheRecord($config, '+', 1, $this->model->{$foreignKey}); + // We only need to do anything if the foreign key was changed. + if (!$this->model->wasChanged($foreignKey)) { + return; } + + // for the minus operation, we first have to get the model that is no longer associated with this one. + $originalRelatedModel = $config->emptyRelatedModel($this->model)->find($this->model->getOriginal($foreignKey)); + + $this->updateCacheValue($originalRelatedModel, $config, -1); + $this->updateCacheValue($config->relatedModel($this->model), $config, 1); }); } /** - * Rebuild the count caches from the database + * Rebuild the count caches from the database for each matching model. */ - public function rebuild() + public function rebuild(): void { - $this->apply(function($config) { - $this->rebuildCacheRecord($config, $this->model, 'COUNT'); + $this->apply(function (CacheConfig $config) { + $this->rebuildCacheRecord($config, $this->model, 'count'); }); } - /** - * Takes a registered counter cache, and setups up defaults. - * - * @param string $cacheKey - * @param array $cacheOptions - * @return array - */ - protected function config($cacheKey, $cacheOptions) + public function increment(): void { - $opts = []; - - if (is_numeric($cacheKey)) { - if (is_array($cacheOptions)) { - // Most explicit configuration provided - $opts = $cacheOptions; - $relatedModel = Arr::get($opts, 'model'); - } else { - // Smallest number of options provided, figure out the rest - $relatedModel = $cacheOptions; - } - } else { - // Semi-verbose configuration provided - $relatedModel = $cacheOptions; - $opts['field'] = $cacheKey; - - if (is_array($cacheOptions)) { - if (isset($cacheOptions[2])) { - $opts['key'] = $cacheOptions[2]; - } - if (isset($cacheOptions[1])) { - $opts['foreignKey'] = $cacheOptions[1]; - } - if (isset($cacheOptions[0])) { - $relatedModel = $cacheOptions[0]; - } - } - } + $this->apply(function (CacheConfig $config) { + $this->updateCacheValue($config->relatedModel($this->model), $config, 1); + }); + } - return $this->defaults($opts, $relatedModel); + public function decrement(): void + { + $this->apply(function (CacheConfig $config) { + $this->updateCacheValue($config->relatedModel($this->model), $config, -1); + }); } /** - * Returns necessary defaults, overwritten by provided options. - * - * @param array $options - * @param string $relatedModel - * @return array + * Takes a registered counter cache, and setups up defaults. */ - protected function defaults($options, $relatedModel) + protected function config($key, string $value): CacheConfig { - $defaults = [ - 'model' => $relatedModel, - 'field' => $this->field($this->model, 'count'), - 'foreignKey' => $this->field($relatedModel, 'id'), - 'key' => 'id' - ]; - - return array_merge($defaults, $options); + return new CacheConfig($key, $value); } } diff --git a/src/Behaviours/CountCache/Countable.php b/src/Behaviours/CountCache/Countable.php deleted file mode 100644 index 8ef6692..0000000 --- a/src/Behaviours/CountCache/Countable.php +++ /dev/null @@ -1,13 +0,0 @@ -rebuild(); + } +} diff --git a/src/Behaviours/CountCache/Observer.php b/src/Behaviours/CountCache/Observer.php index a8c3a81..f732bac 100644 --- a/src/Behaviours/CountCache/Observer.php +++ b/src/Behaviours/CountCache/Observer.php @@ -1,4 +1,5 @@ update($model, '+'); + CountCache::for($model)->increment(); } /** @@ -23,9 +23,9 @@ public function created($model) * * @param $model */ - public function deleted($model) + public function deleted($model): void { - $this->update($model, '-'); + CountCache::for($model)->decrement(); } /** @@ -33,9 +33,9 @@ public function deleted($model) * * @param $model */ - public function updated($model) + public function updated($model): void { - (new CountCache($model))->update(); + CountCache::for($model)->update(); } /** @@ -43,22 +43,8 @@ public function updated($model) * * @param $model */ - public function restored($model) - { - $this->update($model, '+'); - } - - /** - * Handle most update operations of the count cache. - * - * @param $model - * @param string $operation + or - - */ - private function update($model, $operation) + public function restored($model): void { - $countCache = new CountCache($model); - $countCache->apply(function ($config) use ($countCache, $model, $operation) { - $countCache->updateCacheRecord($config, $operation, 1, $model->{$config['foreignKey']}); - }); + CountCache::for($model)->increment(); } } diff --git a/src/Behaviours/CamelCasing.php b/src/Behaviours/HasCamelCasing.php similarity index 96% rename from src/Behaviours/CamelCasing.php rename to src/Behaviours/HasCamelCasing.php index 0fbb82d..322f02a 100644 --- a/src/Behaviours/CamelCasing.php +++ b/src/Behaviours/HasCamelCasing.php @@ -1,9 +1,10 @@ getRelationValue($key); - } - return parent::getAttribute($this->getSnakeKey($key)); } @@ -102,7 +99,7 @@ public function getCasts() } /** - * Converts a given array of attribute keys to the casing required by CamelCaseModel. + * Converts a given array of attribute keys to the casing required by CamelCased. * * @param mixed $attributes * @return array @@ -120,7 +117,7 @@ public function toCamelCase($attributes) } /** - * Converts a given array of attribute keys to the casing required by CamelCaseModel. + * Converts a given array of attribute keys to the casing required by CamelCased. * * @param $attributes * @return array diff --git a/src/Behaviours/Sluggable.php b/src/Behaviours/HasSlugs.php similarity index 71% rename from src/Behaviours/Sluggable.php rename to src/Behaviours/HasSlugs.php index 659a320..9f6ac98 100644 --- a/src/Behaviours/Sluggable.php +++ b/src/Behaviours/HasSlugs.php @@ -1,17 +1,17 @@ generateSlug(); @@ -21,13 +21,13 @@ public static function bootSluggable() /** * Generate a slug based on the main model key. */ - public function generateIdSlug() + public function generateIdSlug(): void { $slug = Slug::fromId($this->getKey() ?? rand()); // Ensure slug is unique (since the fromId() algorithm doesn't produce unique slugs) $attempts = 10; - while ($this->isExistingSlug($slug)) { + while ($this->slugExists($slug)) { if ($attempts <= 0) { throw new UnableToCreateSlugException( "Unable to find unique slug for record '{$this->getKey()}', tried 10 times..." @@ -44,7 +44,7 @@ public function generateIdSlug() /** * Generate a slug string based on the fields required. */ - public function generateTitleSlug(array $fields) + public function generateTitleSlug(array $fields): void { static $attempts = 0; @@ -63,27 +63,22 @@ public function generateTitleSlug(array $fields) /** * Because a title slug can be created from multiple sources (such as an article title, a category title.etc.), * this allows us to search out those fields from related objects and return the combined values. - * - * @param array $fields - * @return array */ - public function getTitleFields(array $fields) + public function getTitleFields(array $fields): array { - $fields = array_map(function ($field) { + return array_map(function ($field) { if (Str::contains($field, '.')) { return object_get($this, $field); // this acts as a delimiter, which we can replace with / } else { return $this->{$field}; } }, $fields); - - return $fields; } /** * Generate the slug for the model based on the model's slug strategy. */ - public function generateSlug() + public function generateSlug(): void { $strategy = $this->slugStrategy(); @@ -94,22 +89,15 @@ public function generateSlug() } } - /** - * Set the value of the slug. - * - * @param $value - */ - public function setSlugValue(Slug $value) + public function setSlugValue(Slug $value): void { $this->{$this->slugField()} = $value; } /** - * Allows laravel to start using the sluggable field as the string for routes. - * - * @return mixed + * Allows laravel to start using the slug field as the string for routes. */ - public function getRouteKey() + public function getRouteKey(): mixed { $slug = $this->slugField(); @@ -118,10 +106,8 @@ public function getRouteKey() /** * Return the name of the field you wish to use for the slug. - * - * @return string */ - protected function slugField() + protected function slugField(): string { return 'slug'; } @@ -148,36 +134,12 @@ protected function slugField() * * @return string */ - public function slugStrategy() + public function slugStrategy(): string { return 'id'; } - /** - * Sets the slug attribute with the Slug value object. - * - * @param Slug $slug - */ - public function setSlugAttribute(Slug $slug) - { - $this->attributes[$this->slugField()] = (string) $slug; - } - - /** - * Returns the slug attribute as a Slug value object. - * - * @return \Eloquence\Behaviours\Slug - */ - public function getSlugAttribute() - { - return new Slug($this->attributes[$this->slugField()]); - } - - /** - * @param Slug $slug - * @return bool - */ - private function isExistingSlug(Slug $slug) + private function slugExists(Slug $slug): bool { return $this->newQuery() ->where($this->slugField(), (string) $slug) diff --git a/src/Behaviours/Slug.php b/src/Behaviours/Slug.php index 0477e24..ca37256 100644 --- a/src/Behaviours/Slug.php +++ b/src/Behaviours/Slug.php @@ -1,4 +1,5 @@ slug; + return $this->slug; } - /** - * Convert the object to its JSON representation. - * - * @param int $options - * @return string - */ - public function toJson($options = 0) + public function toJson($options = 0): string { return $this->__toString(); } diff --git a/src/Behaviours/SumCache/HasSums.php b/src/Behaviours/SumCache/HasSums.php new file mode 100644 index 0000000..4745410 --- /dev/null +++ b/src/Behaviours/SumCache/HasSums.php @@ -0,0 +1,16 @@ +rebuild(); + } +} diff --git a/src/Behaviours/SumCache/Observer.php b/src/Behaviours/SumCache/Observer.php new file mode 100644 index 0000000..a3c7dba --- /dev/null +++ b/src/Behaviours/SumCache/Observer.php @@ -0,0 +1,26 @@ +increase(); + } + + public function updated($model) + { + SumCache::for($model)->update(); + } + + public function deleted($model) + { + SumCache::for($model)->decrease(); + } + + public function restored($model) + { + SumCache::for($model)->increase(); + } +} diff --git a/src/Behaviours/SumCache/SumCache.php b/src/Behaviours/SumCache/SumCache.php index a17d2c7..0b896ab 100644 --- a/src/Behaviours/SumCache/SumCache.php +++ b/src/Behaviours/SumCache/SumCache.php @@ -1,127 +1,80 @@ model = $model; } - /** - * Applies the provided function to the count cache setup/configuration. - * - * @param \Closure $function - */ - public function apply(\Closure $function) + private function configuration(): array { - foreach ($this->model->sumCaches() as $key => $cache) { - $function($this->config($key, $cache)); - } + return $this->reflect(SummedBy::class, function (array $config) { + return [$config['name'] => [$config['attribute']->as => $config['attribute']->from]]; + }); } /** * Rebuild the count caches from the database */ - public function rebuild() + public function rebuild(): void { - $this->apply(function($config) { - $this->rebuildCacheRecord($config, $this->model, 'SUM', $config['columnToSum']); + $this->apply(function ($config) { + $this->rebuildCacheRecord($config, $this->model, 'sum'); }); } - /** - * Update the cache for all operations. - */ - public function update() + public function increase(): void { - $this->apply(function ($config) { - $foreignKey = Str::snake($this->key($config['foreignKey'])); - $amount = $this->model->{$config['columnToSum']}; + $this->apply(function (CacheConfig $config) { + $this->updateCacheValue($config->relatedModel($this->model), $config, (int) $this->model->{$config->sourceField}); + }); + } - if ($this->model->getOriginal($foreignKey) && $this->model->{$foreignKey} != $this->model->getOriginal($foreignKey)) { - $this->updateCacheRecord($config, '-', $amount, $this->model->getOriginal($foreignKey)); - $this->updateCacheRecord($config, '+', $amount, $this->model->{$foreignKey}); - } + public function decrease(): void + { + $this->apply(function (CacheConfig $config) { + $this->updateCacheValue($config->relatedModel($this->model), $config, -(int) $this->model->{$config->sourceField}); }); } /** - * Takes a registered sum cache, and setups up defaults. - * - * @param string $cacheKey - * @param array $cacheOptions - * @return array + * Update the cache for all operations. */ - protected function config($cacheKey, $cacheOptions) + public function update(): void { - $opts = []; + $this->apply(function (CacheConfig $config) { + $foreignKey = $config->foreignKeyName($this->model); - if (is_numeric($cacheKey)) { - if (is_array($cacheOptions)) { - // Most explicit configuration provided - $opts = $cacheOptions; - $relatedModel = Arr::get($opts, 'model'); + if ($this->model->wasChanged($foreignKey)) { + // for the minus operation, we first have to get the model that is no longer associated with this one. + $originalRelatedModel = $config->emptyRelatedModel($this->model)->find($this->model->getOriginal($foreignKey)); + $this->updateCacheValue($originalRelatedModel, $config, -$this->model->getOriginal($config->sourceField)); + $this->updateCacheValue($config->relatedModel($this->model), $config, $this->model->{$config->sourceField}); } else { - // Smallest number of options provided, figure out the rest - $relatedModel = $cacheOptions; + $difference = $this->model->{$config->sourceField} - $this->model->getOriginal($config->sourceField); + $this->updateCacheValue($config->relatedModel($this->model), $config, $difference); } - } else { - // Semi-verbose configuration provided - $relatedModel = $cacheOptions; - $opts['field'] = $cacheKey; - - if (is_array($cacheOptions)) { - if (isset($cacheOptions[3])) { - $opts['key'] = $cacheOptions[3]; - } - if (isset($cacheOptions[2])) { - $opts['foreignKey'] = $cacheOptions[2]; - } - if (isset($cacheOptions[1])) { - $opts['columnToSum'] = $cacheOptions[1]; - } - if (isset($cacheOptions[0])) { - $relatedModel = $cacheOptions[0]; - } - } - } - - return $this->defaults($opts, $relatedModel); + }); } /** - * Returns necessary defaults, overwritten by provided options. - * - * @param array $options - * @param string $relatedModel - * @return array + * Takes a registered sum cache, and setups up defaults. */ - protected function defaults($options, $relatedModel) + protected function config($relation, string|array $sourceField): CacheConfig { - $defaults = [ - 'model' => $relatedModel, - 'columnToSum' => 'total', - 'field' => $this->field($this->model, 'total'), - 'foreignKey' => $this->field($relatedModel, 'id'), - 'key' => 'id' - ]; + $keys = array_keys($sourceField); + + $aggregateField = $keys[0]; + $sourceField = $sourceField[$aggregateField]; - return array_merge($defaults, $options); + return new CacheConfig($relation, $aggregateField, $sourceField); } } diff --git a/src/Behaviours/SumCache/Summable.php b/src/Behaviours/SumCache/Summable.php index a2a7a1d..3e7a68c 100644 --- a/src/Behaviours/SumCache/Summable.php +++ b/src/Behaviours/SumCache/Summable.php @@ -1,40 +1,27 @@ apply(function ($config) use ($model, $sumCache) { - $sumCache->updateCacheRecord($config, '+', $model->{$config['columnToSum']}, $model->{$config['foreignKey']}); - }); - }); - - static::updated(function ($model) { - (new SumCache($model))->update(); - }); - - static::deleted(function ($model) { - $sumCache = new SumCache($model); - $sumCache->apply(function ($config) use ($model, $sumCache) { - $sumCache->updateCacheRecord($config, '-', $model->{$config['columnToSum']}, $model->{$config['foreignKey']}); - }); - }); - } - /** - * Return the sum cache configuration for the model. + * Returns a key->value array of the relationship you want to utilise to update the sum, followed + * by the source field you wish to sum. For example, if you have an order model that has many items + * and you wish to sum the item amount, you can return the following: + * + * ['order' => 'amount'] + * + * Of course, if you want to customise the field saving the total as well, you can do that too: + * + * ['relationship' => ['aggregate_field' => 'source_field']] + * + * In real-world terms: + * + * ['order' => ['total_amount' => 'amount']] + * + * By default, the sum cache will take the source field, and add "_total" to it on the related model. * * @return array */ - abstract public function sumCaches(); + public function summedBy(): array; } diff --git a/src/Behaviours/SumCache/SummedBy.php b/src/Behaviours/SumCache/SummedBy.php new file mode 100644 index 0000000..47d46ee --- /dev/null +++ b/src/Behaviours/SumCache/SummedBy.php @@ -0,0 +1,13 @@ +getKeyName()) - */ - static::creating(function ($model) { - $key = $model->getKeyName(); - - if (empty($model->$key)) { - $model->$key = (string) $model->generateNewUuid(); - } - }); - } - - /** - * Get a new version 4 (random) UUID. - * - * @return \Rhumsaa\Uuid\Uuid - */ - public function generateNewUuid() - { - return RamseyUuid::uuid4(); - } -} diff --git a/src/Commands/FindCacheableClasses.php b/src/Commands/FindCacheableClasses.php deleted file mode 100644 index 5b249a8..0000000 --- a/src/Commands/FindCacheableClasses.php +++ /dev/null @@ -1,48 +0,0 @@ -directory = realpath($directory); - } - - public function getAllCacheableClasses() - { - $finder = new Finder; - $iterator = new ClassIterator($finder->in($this->directory)); - $iterator->enableAutoloading(); - - $classes = []; - - foreach ($iterator->type(Model::class) as $className => $class) { - if ($class->isInstantiable() && $this->usesCaching($class)) { - $classes[] = $className; - } - } - - return $classes; - } - - /** - * Decide if the class uses any of the caching behaviours. - * - * @param \ReflectionClass $class - * - * @return bool - */ - private function usesCaching(\ReflectionClass $class) - { - return $class->hasMethod('bootCountable') || $class->hasMethod('bootSummable'); - } -} diff --git a/src/Commands/RebuildCaches.php b/src/Commands/RebuildCaches.php deleted file mode 100644 index 372e2d4..0000000 --- a/src/Commands/RebuildCaches.php +++ /dev/null @@ -1,77 +0,0 @@ -option('class')) { - $classes = [$class]; - } else { - $directory = $this->option('dir') ?: app_path(); - $classes = (new FindCacheableClasses($directory))->getAllCacheableClasses(); - } - foreach ($classes as $className) { - $this->rebuild($className); - } - } - - /** - * Rebuilds the caches for the given class. - * - * @param string $className - */ - private function rebuild($className) - { - $instance = new $className; - - if (method_exists($instance, 'countCaches')) { - $this->info("Rebuilding [$className] count caches"); - $countCache = new CountCache($instance); - $countCache->rebuild(); - } - - if (method_exists($instance, 'sumCaches')) { - $this->info("Rebuilding [$className] sum caches"); - $sumCache = new SumCache($instance); - $sumCache->rebuild(); - } - } - -} diff --git a/src/Database/Model.php b/src/Database/Model.php index 22ceb2f..fe8ce7e 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -1,8 +1,8 @@ app->bind('Illuminate\Database\Eloquent\Model', 'Eloquence\Database\Model'); + $this->publishes([ + __DIR__.'/../config/eloquence.php' => config_path('eloquence.php'), + ], 'config'); + + $this->initialiseDbQueryLog(); + $this->initialiseCommands(); } - /** - * Register the service provider. - * - * @return void - */ - public function register() + protected function initialiseDbQueryLog(): void { - $this->app->bind('command.eloquence:rebuild', RebuildCaches::class); + DBQueryLog::initialise(); + } - $this->commands(['command.eloquence:rebuild']); + private function initialiseCommands(): void + { + $this->commands([ + Utilities\RebuildCaches::class, + ]); } } diff --git a/src/Exceptions/UnableToCreateSlugException.php b/src/Exceptions/UnableToCreateSlugException.php index 37416f4..d50e3d8 100644 --- a/src/Exceptions/UnableToCreateSlugException.php +++ b/src/Exceptions/UnableToCreateSlugException.php @@ -1,4 +1,5 @@ debug("[{$query->time}ms] $query->sql", $query->bindings); + }); + } +} diff --git a/src/Utilities/RebuildCaches.php b/src/Utilities/RebuildCaches.php new file mode 100644 index 0000000..e2c28d1 --- /dev/null +++ b/src/Utilities/RebuildCaches.php @@ -0,0 +1,99 @@ + 'rebuildCountCache', + HasSums::class => 'rebuildSumCache', + ]; + + public function handle(): void + { + $path = $this->argument('path') ?? app_path(); + + $this->allModelsUsingCaches($path)->each(function (string $class) { + $traits = class_uses_recursive($class); + + foreach ($this->caches as $trait => $method) { + if (!in_array($trait, $traits)) { + continue; + } + + $class::$method(); + } + }); + } + + /** + * Returns only those models that are utilising eloquence cache mechanisms. + * + * @param string $path + * @return Collection + */ + private function allModelsUsingCaches(string $path): Collection + { + return collect(Finder::create()->files()->in($path)->name('*.php')) + ->filter(fn (SplFileInfo $file) => $file->getFilename()[0] === Str::upper($file->getFilename()[0])) + ->map(fn (SplFileInfo $file) => $this->fullyQualifiedClassName($file)) + ->filter(fn (string $class) => is_subclass_of($class, Model::class)) + ->filter(fn (string $class) => $this->usesCaches($class)); + } + + /** + * Determines the fully qualified class name of the provided file. + * + * @param SplFileInfo $file + * @return string + */ + private function fullyQualifiedClassName(SplFileInfo $file) + { + $tokens = \PhpToken::tokenize($file->getContents()); + $namespace = null; + $class = null; + + foreach ($tokens as $i => $token) { + if ($token->is(T_NAMESPACE)) { + $namespace = $tokens[$i + 2]->text; + } + + if ($token->is(T_CLASS)) { + $class = $tokens[$i + 2]->text; + } + + if ($namespace && $class) { + break; + } + } + + if (!$namespace || !$class) { + $this->error(sprintf('Could not find namespace or class in %s', $file->getRealPath())); + } + + return sprintf('%s\\%s', $namespace, $class); + } + + /** + * Returns true if the provided class uses any of the caches provided by Eloquence. + * + * @param string $class + * @return bool + */ + private function usesCaches(string $class): bool + { + return (bool) array_intersect(class_uses_recursive($class), array_keys($this->caches)); + } +} diff --git a/tests/Acceptance/AcceptanceTestCase.php b/tests/Acceptance/AcceptanceTestCase.php index 7a6ca39..7c3d52f 100644 --- a/tests/Acceptance/AcceptanceTestCase.php +++ b/tests/Acceptance/AcceptanceTestCase.php @@ -1,7 +1,9 @@ init(); } + protected function getPackageProviders($app) + { + return [ + EloquenceServiceProvider::class, + ]; + } + protected function getEnvironmentSetUp($app) { $app['config']->set('database.default', 'test'); @@ -33,17 +42,17 @@ private function migrate() { Schema::create('users', function (Blueprint $table) { $table->increments('id'); - $table->string('first_name'); - $table->string('last_name'); + $table->string('first_name')->nullable(); + $table->string('last_name')->nullable(); $table->string('slug')->nullable(); $table->integer('comment_count')->default(0); $table->integer('post_count')->default(0); - $table->integer('post_count_explicit')->default(0); $table->timestamps(); }); Schema::create('posts', function (Blueprint $table) { $table->increments('id'); + $table->integer('category_id')->nullable(); $table->integer('user_id')->nullable(); $table->string('slug')->nullable(); $table->integer('comment_count')->default(0); @@ -60,15 +69,22 @@ private function migrate() Schema::create('orders', function (Blueprint $table) { $table->increments('id'); - $table->integer('item_total')->default(0); - $table->integer('item_total_explicit')->default(0); + $table->integer('total_amount')->default(0); $table->timestamps(); }); Schema::create('items', function (Blueprint $table) { $table->increments('id'); $table->integer('order_id'); - $table->integer('total'); + $table->integer('amount'); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('categories', function (Blueprint $table) { + $table->increments('id'); + $table->integer('post_count')->default(0); + $table->integer('total_comments')->default(0); $table->timestamps(); }); } diff --git a/tests/Acceptance/ChainedAggregatesTest.php b/tests/Acceptance/ChainedAggregatesTest.php new file mode 100644 index 0000000..6a6d65a --- /dev/null +++ b/tests/Acceptance/ChainedAggregatesTest.php @@ -0,0 +1,18 @@ +create(); + + $this->assertSame(1, Category::first()->postCount); + $this->assertSame(1, Category::first()->totalComments); + } +} \ No newline at end of file diff --git a/tests/Acceptance/CountCacheTest.php b/tests/Acceptance/CountCacheTest.php index b56715c..1fd0a23 100644 --- a/tests/Acceptance/CountCacheTest.php +++ b/tests/Acceptance/CountCacheTest.php @@ -7,72 +7,40 @@ class CountCacheTest extends AcceptanceTestCase { - private $data = []; - - public function init() - { - $this->data = $this->setupUserAndPost(); - } - - public function testUserCountCache() + function test_userHasASinglePostCount() { - $user = User::first(); + Post::factory()->create(); - $this->assertEquals(1, $user->postCount); - $this->assertEquals(1, $user->postCountExplicit); + $this->assertEquals(1, User::first()->postCount); } - public function testComplexCountCache() + function test_whenRelatedModelsAreSwitchedBothCountCachesAreUpdated() { - $post = new Post; - $post->userId = $this->data['user']->id; - $post->save(); - - $comment = new Comment; - $comment->userId = $this->data['user']->id; - $comment->postId = $this->data['post']->id; - $comment->save(); - - $this->assertEquals(2, User::first()->postCount); - $this->assertEquals(2, User::first()->postCountExplicit); + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $posts = Post::factory()->count(2)->for($user1)->create(); + $comment = Comment::factory()->for($user1)->for($posts->first())->create(); - $this->assertEquals(1, User::first()->commentCount); - $this->assertEquals(1, Post::first()->commentCount); + $this->assertEquals(2, $user1->fresh()->postCount); + $this->assertEquals(1, $user1->fresh()->commentCount); + $this->assertEquals(1, $posts->first()->fresh()->commentCount); - $comment->postId = $post->id; + $comment = $comment->fresh(); + $comment->userId = $user2->id; $comment->save(); - $this->assertEquals(0, Post::first()->commentCount); - $this->assertEquals(1, Post::get()[1]->commentCount); + $this->assertEquals(0, $user1->fresh()->commentCount); + $this->assertEquals(1, $user2->fresh()->commentCount); } - public function testItCanHandleNegativeCounts() + public function testItCanHandleModelRestoration() { - $post = new Post; - $post->userId = $this->data['user']->id; - $post->save(); + $post = Post::factory()->create(); - $comment = new Comment; - $comment->userId = $this->data['user']->id; - $comment->postId = $this->data['post']->id; - $comment->save(); + $comment = Comment::factory()->for($post)->create(); $comment->delete(); $comment->restore(); - $this->assertEquals(1, Post::first()->commentCount); - } - - private function setupUserAndPost() - { - $user = new User; - $user->firstName = 'Kirk'; - $user->lastName = 'Bushell'; - $user->save(); - - $post = new Post; - $post->userId = $user->id; - $post->save(); - - return compact('user', 'post'); + $this->assertEquals(1, $post->fresh()->commentCount); } } diff --git a/tests/Acceptance/HasSlugsTest.php b/tests/Acceptance/HasSlugsTest.php new file mode 100644 index 0000000..49ec54a --- /dev/null +++ b/tests/Acceptance/HasSlugsTest.php @@ -0,0 +1,22 @@ + 'Kirk', 'lastName' => 'Bushell'])->create(); + + $this->assertEquals('kirk-bushell', $user->slug); + } + + function test_slugsCanBeGeneratedUsingRandomValues() + { + $post = Post::factory()->create(); + + $this->assertMatchesRegularExpression('/^[a-z0-9]{8}$/i', $post->slug); + } +} diff --git a/tests/Acceptance/Models/Category.php b/tests/Acceptance/Models/Category.php new file mode 100644 index 0000000..845b55a --- /dev/null +++ b/tests/Acceptance/Models/Category.php @@ -0,0 +1,23 @@ +belongsTo(Post::class); + } + + #[CountedBy] + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected static function newFactory(): Factory { - return [ - 'Tests\Acceptance\Models\Post', - 'Tests\Acceptance\Models\User', - ]; + return CommentFactory::new(); } } diff --git a/tests/Acceptance/Models/CommentFactory.php b/tests/Acceptance/Models/CommentFactory.php new file mode 100644 index 0000000..8337cf2 --- /dev/null +++ b/tests/Acceptance/Models/CommentFactory.php @@ -0,0 +1,18 @@ + Post::factory(), + 'user_id' => User::factory(), + ]; + } +} \ No newline at end of file diff --git a/tests/Acceptance/Models/GuardedUser.php b/tests/Acceptance/Models/GuardedUser.php index a8fd437..6ceb156 100644 --- a/tests/Acceptance/Models/GuardedUser.php +++ b/tests/Acceptance/Models/GuardedUser.php @@ -1,12 +1,12 @@ belongsTo(Order::class); + } + + protected static function newFactory(): Factory { - return [ - 'Tests\Acceptance\Models\Order', - [ - 'model' => 'Tests\Acceptance\Models\Order', - 'field' => 'itemTotalExplicit', - 'columnToSum' => 'total', - 'foreignKey' => 'orderId', - 'key' => 'id', - ] - ]; + return ItemFactory::new(); } } diff --git a/tests/Acceptance/Models/ItemFactory.php b/tests/Acceptance/Models/ItemFactory.php new file mode 100644 index 0000000..d2448b3 --- /dev/null +++ b/tests/Acceptance/Models/ItemFactory.php @@ -0,0 +1,18 @@ + Order::factory(), + 'amount' => fake()->numberBetween(0, 100), + ]; + } +} \ No newline at end of file diff --git a/tests/Acceptance/Models/Order.php b/tests/Acceptance/Models/Order.php index 56092f0..6c376ad 100644 --- a/tests/Acceptance/Models/Order.php +++ b/tests/Acceptance/Models/Order.php @@ -1,15 +1,23 @@ hasMany('Tests\Acceptance\Models\Item'); + return $this->hasMany(Item::class); + } + + protected static function newFactory(): Factory + { + return OrderFactory::new(); } } diff --git a/tests/Acceptance/Models/OrderFactory.php b/tests/Acceptance/Models/OrderFactory.php new file mode 100644 index 0000000..779387a --- /dev/null +++ b/tests/Acceptance/Models/OrderFactory.php @@ -0,0 +1,15 @@ + ['Tests\Acceptance\Models\User', 'userId', 'id'], - [ - 'model' => 'Tests\Acceptance\Models\User', - 'field' => 'postCountExplicit', - 'foreignKey' => 'userId', - 'key' => 'id', - ] - ]; + return $this->belongsTo(User::class); } public function slugStrategy() { return 'id'; } + + #[CountedBy] + #[SummedBy(from: 'comment_count', as: 'total_comments')] + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + protected static function newFactory(): Factory + { + return PostFactory::new(); + } } diff --git a/tests/Acceptance/Models/PostFactory.php b/tests/Acceptance/Models/PostFactory.php new file mode 100644 index 0000000..45c38d0 --- /dev/null +++ b/tests/Acceptance/Models/PostFactory.php @@ -0,0 +1,18 @@ + Category::factory(), + 'user_id' => User::factory(), + ]; + } +} \ No newline at end of file diff --git a/tests/Acceptance/Models/Role.php b/tests/Acceptance/Models/Role.php new file mode 100644 index 0000000..ccfd169 --- /dev/null +++ b/tests/Acceptance/Models/Role.php @@ -0,0 +1,13 @@ +hasMany(Post::class); } @@ -19,4 +27,9 @@ public function slugStrategy() { return ['firstName', 'lastName']; } + + protected static function newFactory(): Factory + { + return UserFactory::new(); + } } diff --git a/tests/Acceptance/Models/UserFactory.php b/tests/Acceptance/Models/UserFactory.php new file mode 100644 index 0000000..357e7f1 --- /dev/null +++ b/tests/Acceptance/Models/UserFactory.php @@ -0,0 +1,18 @@ + fake()->firstName(), + 'lastName' => fake()->lastName(), + ]; + } +} \ No newline at end of file diff --git a/tests/Acceptance/RebuildCacheTest.php b/tests/Acceptance/RebuildCacheTest.php new file mode 100644 index 0000000..c74bd17 --- /dev/null +++ b/tests/Acceptance/RebuildCacheTest.php @@ -0,0 +1,45 @@ +create(); + $user2 = User::factory()->create(); + + Post::factory()->count(5)->for($user1)->create(); + Post::factory()->count(2)->for($user2)->create(); + + $user1->postCount = 0; + $user1->save(); + $user2->postCount = 3; + $user2->save(); + + Post::rebuildCountCache(); + + $this->assertEquals(5, $user1->fresh()->postCount); + $this->assertEquals(2, $user2->fresh()->postCount); + } + + function test_sumCachesCanBeRebuilt() + { + $order = Order::factory()->create(); + Item::factory()->count(3)->for($order)->create(['amount' => 10]); + + $order->totalAmount = 50; + $order->save(); + + $this->assertEquals(50, $order->fresh()->totalAmount); + + Item::rebuildSumCache(); + + $this->assertEquals(30, $order->fresh()->totalAmount); + } +} \ No newline at end of file diff --git a/tests/Acceptance/RebuildCachesCommandTest.php b/tests/Acceptance/RebuildCachesCommandTest.php new file mode 100644 index 0000000..630b3b7 --- /dev/null +++ b/tests/Acceptance/RebuildCachesCommandTest.php @@ -0,0 +1,41 @@ +create(['total_amount' => 0]); + $order2 = Order::factory()->create(['total_amount' => 0]); + + Item::factory()->for($order1)->count(10)->create(['amount' => 10]); + Item::factory()->for($order2)->count(5)->create(['amount' => 5]); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + Post::factory()->for($user1)->count(10)->create(); + Post::factory()->for($user2)->count(5)->create(); + + $order1->totalAmount = 0; + $order1->save(); + + $order2->totalAmount = 0; + $order2->save(); + + $result = $this->artisan('eloquence:rebuild-caches '.__DIR__.'/../../tests/Acceptance/Models'); + + $result->assertExitCode(0); + + $this->assertDatabaseHas('users', ['post_count' => 10]); + $this->assertDatabaseHas('users', ['post_count' => 5]); + $this->assertDatabaseHas('orders', ['total_amount' => 100]); + $this->assertDatabaseHas('orders', ['total_amount' => 25]); + } +} \ No newline at end of file diff --git a/tests/Acceptance/SluggedTest.php b/tests/Acceptance/SluggedTest.php deleted file mode 100644 index 9321245..0000000 --- a/tests/Acceptance/SluggedTest.php +++ /dev/null @@ -1,26 +0,0 @@ -firstName = 'Kirk'; - $user->lastName = 'Bushell'; - $user->save(); - - $this->assertEquals('kirk-bushell', (string) $user->slug); - } - - public function testPostSlug() - { - $post = new Post; - $post->save(); - - $this->assertMatchesRegularExpression('/^[a-z0-9]{8}$/i', (string) $post->slug); - } -} diff --git a/tests/Acceptance/SumCacheTest.php b/tests/Acceptance/SumCacheTest.php index 8571eb3..1c2d34a 100644 --- a/tests/Acceptance/SumCacheTest.php +++ b/tests/Acceptance/SumCacheTest.php @@ -6,57 +6,64 @@ class SumCacheTest extends AcceptanceTestCase { - private $data = []; - - public function init() + function test_relatedModelSumCacheIsIncreasedWhenModelIsCreated() { - $this->data = $this->setupOrderAndItem(); + Item::factory()->create(['amount' => 34]); + + $this->assertEquals(34, Order::first()->totalAmount); } - public function testOrderSumCache() + function test_relatedModelSumCacheIsDecreasedWhenModelIsDeleted() { - $order = Order::first(); + $item = Item::factory()->create(['amount' => 19]); + $item->delete(); - $this->assertEquals(34, $order->itemTotal); - $this->assertEquals(34, $order->itemTotalExplicit); + $this->assertEquals(0, Order::first()->totalAmount); } - public function testAdditionalSumCache() + function test_whenAnAggregatedModelValueSwitchesContext() { - $order = new Order; - $order->save(); + $item = Item::factory()->create(); + $newOrder = Order::factory()->create(); - $item = new Item; - $item->orderId = $this->data['order']->id; - $item->total = 45; + $item = $item->fresh(); + $item->orderId = $newOrder->id; $item->save(); - $this->assertEquals(79, Order::first()->itemTotal); - $this->assertEquals(0, Order::get()[1]->itemTotal); + $this->assertEquals(0, Order::first()->totalAmount); + $this->assertEquals($item->amount, $newOrder->fresh()->totalAmount); + } - $this->assertEquals(79, Order::first()->itemTotalExplicit); - $this->assertEquals(0, Order::get()[1]->itemTotalExplicit); + function test_aggregateValuesAreUpdatedWhenModelsAreRestored() + { + $item = Item::factory()->create(); + $item->delete(); // Triggers decrease in order total + $item->restore(); // Restores order total - $item->orderId = $order->id; - $item->save(); + $this->assertEquals($item->amount, Order::first()->totalAmount); + } - $this->assertEquals(34, Order::first()->itemTotal); - $this->assertEquals(45, Order::get()[1]->itemTotal); + function test_aggregateValueIsSetToCorrectAmountWhenSourceFieldChanges() + { + $item = Item::factory()->create(); + $item->amount = 20; + $item->save(); - $this->assertEquals(34, Order::first()->itemTotalExplicit); - $this->assertEquals(45, Order::get()[1]->itemTotalExplicit); + $this->assertEquals(20, Order::first()->totalAmount); } - private function setupOrderAndItem() + function test_aggregateValueOnOriginalRelatedModelIsUpdatedCorrectlyWhenTheForeignKeyAndAmountIsChanged() { - $order = new Order; - $order->save(); + $item = Item::factory()->create(); + + $newOrder = Order::factory()->create(); - $item = new Item; - $item->total = 34; - $item->orderId = $order->id; + $item = $item->fresh(); + $item->amount = 20; + $item->orderId = $newOrder->id; $item->save(); - return compact('order', 'item'); + $this->assertEquals(0, Order::first()->totalAmount); + $this->assertEquals(20, $newOrder->fresh()->totalAmount); } } diff --git a/tests/Unit/Behaviours/SlugTest.php b/tests/Unit/Behaviours/SlugTest.php index 7702858..bb17ebb 100644 --- a/tests/Unit/Behaviours/SlugTest.php +++ b/tests/Unit/Behaviours/SlugTest.php @@ -10,4 +10,9 @@ public function test_random_slug_is_random() { $this->assertNotEquals(Slug::random(), Slug::random()); } + + public function test_slugs_are_8_characters_long() + { + $this->assertEquals(8, strlen((string) Slug::random())); + } } diff --git a/tests/Unit/Database/Traits/CamelCaseModelTest.php b/tests/Unit/Database/Traits/HasCamelCasingTest.php similarity index 98% rename from tests/Unit/Database/Traits/CamelCaseModelTest.php rename to tests/Unit/Database/Traits/HasCamelCasingTest.php index de66563..f8e036c 100644 --- a/tests/Unit/Database/Traits/CamelCaseModelTest.php +++ b/tests/Unit/Database/Traits/HasCamelCasingTest.php @@ -7,7 +7,7 @@ use Tests\Unit\Stubs\RealModelStub; use Tests\Unit\TestCase; -class CamelCaseModelTest extends TestCase +class HasCamelCasingTest extends TestCase { private $model; diff --git a/tests/Unit/Stubs/CountCache/Comment.php b/tests/Unit/Stubs/CountCache/Comment.php index c51a360..c505250 100644 --- a/tests/Unit/Stubs/CountCache/Comment.php +++ b/tests/Unit/Stubs/CountCache/Comment.php @@ -1,12 +1,12 @@ 'Kirk', diff --git a/tests/Unit/Stubs/PivotModelStub.php b/tests/Unit/Stubs/PivotModelStub.php index 4a6edc8..78527f6 100644 --- a/tests/Unit/Stubs/PivotModelStub.php +++ b/tests/Unit/Stubs/PivotModelStub.php @@ -1,11 +1,11 @@ 'Kirk', diff --git a/tests/Unit/Stubs/RealModelStub.php b/tests/Unit/Stubs/RealModelStub.php index ed08dd0..3fc25b2 100644 --- a/tests/Unit/Stubs/RealModelStub.php +++ b/tests/Unit/Stubs/RealModelStub.php @@ -2,13 +2,13 @@ namespace Tests\Unit\Stubs; use Eloquence\Behaviours\CountCache\CacheConfig; -use Eloquence\Behaviours\CountCache\Countable; +use Eloquence\Behaviours\CountCache\HasCounts; use Eloquence\Behaviours\CountCache\CountCache; use Eloquence\Database\Model; class RealModelStub extends Model { - use \Eloquence\Behaviours\CountCache\Countable; + use \Eloquence\Behaviours\CountCache\HasCounts; protected $dateFormat = \DateTime::ISO8601; diff --git a/tests/Unit/Stubs/SumCache/Item.php b/tests/Unit/Stubs/SumCache/Item.php index de0d14f..c697222 100644 --- a/tests/Unit/Stubs/SumCache/Item.php +++ b/tests/Unit/Stubs/SumCache/Item.php @@ -1,12 +1,12 @@