diff --git a/README.md b/README.md index ed4747d..a242e1e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ data from our Eloquent models persist through to our APIs in a camel-case manner 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; + use \Eloquence\Behaviours\CamelCased; Put the above line in your models and that's it. @@ -49,15 +49,15 @@ snake_case of field names is the defacto standard within the Laravel community : ## 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 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. To get this working, you need to do two steps: @@ -66,13 +66,14 @@ To get this working, you need to do two steps: #### Configure the count cache -To setup the count cache configuration, we need to have the model use Countable trait, like so: +To setup the count cache configuration, we need to have the model use the Countable interface, and setup the basic +functionality with the HasCounts trait, like so: ```php -class Post extends Eloquent { - use Countable; +class Post extends Eloquent implements Countable { + use HasCounts; - public function countCaches() { + public function countedBy() { return [User::class]; } } @@ -91,10 +92,10 @@ The example above uses the following standard conventions: These are, however, configurable: ```php -class Post extends Eloquent { - use Countable; +class Post extends Eloquent implements Countable { + use HasCounts; - public function countCaches() { + public function countedBy() { return [ 'num_posts' => ['User', 'users_id', 'id'] ]; @@ -129,6 +130,10 @@ and "key" parameters will be calculated using the standard conventions mentioned With this configuration now setup - you're ready to go! +Note: Because the various behaviours often execute multiple queries at once for updating relevant +models, it's a good idea to wrap your save operations in database transaction calls, in case one of them fails. +This will help to prevent your database getting out of sync if there's ever a problem with a single datbabase query. + ### Sum cache diff --git a/src/Behaviours/CacheConfig.php b/src/Behaviours/CacheConfig.php new file mode 100644 index 0000000..ba0dcca --- /dev/null +++ b/src/Behaviours/CacheConfig.php @@ -0,0 +1,37 @@ +{$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. + * + * @param Model $model + * @return Model + */ + public function emptyRelatedModel(Model $model): Model + { + return $this->relation($model)->getQuery()->getModel(); + } + + 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..44aaa3f 100644 --- a/src/Behaviours/Cacheable.php +++ b/src/Behaviours/Cacheable.php @@ -2,41 +2,29 @@ namespace Eloquence\Behaviours; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +/** + * The cacheable trait is concerned with the related models. + */ trait Cacheable { /** * Updates a table's record based on the query information provided in the $config variable. * - * @param array $config * @param string $operation Whether to increase or decrease a value. Valid values: +/- - * @param int|float|double $amount - * @param string $foreignKey */ - public function updateCacheRecord(array $config, $operation, $amount, $foreignKey) + public function updateCacheRecord(Model $model, CacheConfig $config, string $operation): void { - if (is_null($foreignKey)) { - return; - } - - $config = $this->processConfig($config); - - $sql = DB::table($config['table'])->where($config['key'], $foreignKey); - - /* - * Increment for + operator - */ if ($operation == '+') { - return $sql->increment($config['field'], $amount); + $this->updateCacheValue($model, $config, 1); + return; } - /* - * Decrement for - operator - */ - return $sql->decrement($config['field'], $amount); + $this->updateCacheValue($model, $config, -1); } /** @@ -77,37 +65,10 @@ public function rebuildCacheRecord(array $config, Model $model, $command, $aggre ]); } - /** - * Creates the key based on model properties and rules. - * - * @param string $model - * @param string $field - * - * @return string - */ - protected function field($model, $field) - { - $class = strtolower(class_basename($model)); - $field = $class . '_' . $field; - - return $field; - } - - /** - * Process configuration parameters to check key names, fix snake casing, etc.. - * - * @param array $config - * @return array - */ - protected function processConfig(array $config) + public function updateCacheValue(Model $model, CacheConfig $config, 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'])), - ]; + $model->{$config->countField} = $model->{$config->countField} + $amount; + $model->save(); } /** @@ -124,21 +85,4 @@ protected function key($field) return $field; } - - /** - * 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 - */ - protected function getModelTable($model) - { - if (!is_object($model)) { - $model = new $model; - } - - return DB::getTablePrefix().$model->getTable(); - } - } diff --git a/src/Behaviours/CamelCasing.php b/src/Behaviours/CamelCased.php similarity index 98% rename from src/Behaviours/CamelCasing.php rename to src/Behaviours/CamelCased.php index ac9bfd1..dbf04e0 100644 --- a/src/Behaviours/CamelCasing.php +++ b/src/Behaviours/CamelCased.php @@ -3,7 +3,7 @@ use Illuminate\Support\Str; -trait CamelCasing +trait CamelCased { /** * Alter eloquent model behaviour so that model attributes can be accessed via camelCase, but more importantly, @@ -98,7 +98,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 @@ -116,7 +116,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/CountCache/CountCache.php b/src/Behaviours/CountCache/CountCache.php index 8247bd3..07b04c8 100644 --- a/src/Behaviours/CountCache/CountCache.php +++ b/src/Behaviours/CountCache/CountCache.php @@ -2,25 +2,22 @@ namespace Eloquence\Behaviours\CountCache; use Eloquence\Behaviours\Cacheable; +use Eloquence\Behaviours\CacheConfig; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Arr; use Illuminate\Support\Str; +/** + * The count cache does operations on the model that has just updated, works out the related models, and calls the appropriate operations on cacheable. + */ class CountCache { use Cacheable; - /** - * @var Model - */ - private $model; + private function __construct(private Countable $model) {} - /** - * @param Model $model - */ - public function __construct(Model $model) + public static function for(Countable $model): self { - $this->model = $model; + return new self($model); } /** @@ -30,23 +27,28 @@ public function __construct(Model $model) */ public function apply(\Closure $function) { - foreach ($this->model->countCaches() as $key => $cache) { - $function($this->config($key, $cache)); + foreach ($this->model->countedBy() as $key => $value) { + $function($this->config($key, $value)); } } /** - * Update the cache for all operations. + * Update the count cache for all related models. */ public function update() { - $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)) return; - 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}); - } +// dd($this->model); + // 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)); + + $originalRelatedModel->decrement($config->countField); + + $this->increment(); }); } @@ -60,63 +62,32 @@ public function rebuild() }); } + public function increment(): void + { + $this->apply(fn(CacheConfig $config) => $config->relation($this->model)->increment($config->countField)); + } + + public function decrement(): void + { + $this->apply(fn(CacheConfig $config) => $config->relation($this->model)->decrement($config->countField)); + } + /** * Takes a registered counter cache, and setups up defaults. - * - * @param string $cacheKey - * @param array $cacheOptions - * @return array */ - protected function config($cacheKey, $cacheOptions) + protected function config($key, string $value): CacheConfig { - $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]; - } - } + // If the key is numeric, it means only the relationship method has been referenced. + if (is_numeric($key)) { + $key = $value; + $value = $this->defaultCountField(); } - return $this->defaults($opts, $relatedModel); + return new CacheConfig($key, $value); } - /** - * Returns necessary defaults, overwritten by provided options. - * - * @param array $options - * @param string $relatedModel - * @return array - */ - protected function defaults($options, $relatedModel) + private function defaultCountField(): string { - $defaults = [ - 'model' => $relatedModel, - 'field' => $this->field($this->model, 'count'), - 'foreignKey' => $this->field($relatedModel, 'id'), - 'key' => 'id' - ]; - - return array_merge($defaults, $options); + return Str::lower(Str::snake(class_basename($this->model))).'_count'; } } diff --git a/src/Behaviours/CountCache/Countable.php b/src/Behaviours/CountCache/Countable.php index 8ef6692..61fc9b4 100644 --- a/src/Behaviours/CountCache/Countable.php +++ b/src/Behaviours/CountCache/Countable.php @@ -1,13 +1,21 @@ value array of the relationship you want to utilise to update the count, followed + * by the field on that related model. For example, if you have a user model that has many posts + * you can return the following: + * + * ['user'] + * + * Of course you can customise the count field: + * + * ['user' => 'post_total'] + * + * @return array */ - public static function bootCountable() - { - static::observe(Observer::class); - } -} + public function countedBy(): array; +} \ No newline at end of file diff --git a/src/Behaviours/CountCache/HasCounts.php b/src/Behaviours/CountCache/HasCounts.php new file mode 100644 index 0000000..f407443 --- /dev/null +++ b/src/Behaviours/CountCache/HasCounts.php @@ -0,0 +1,13 @@ +update($model, '+'); + CountCache::for($model)->increment(); } /** @@ -23,9 +25,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 +35,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 +45,20 @@ public function updated($model) * * @param $model */ - public function restored($model) + public function restored($model): void { - $this->update($model, '+'); + CountCache::for($model)->increment($model); } /** * Handle most update operations of the count cache. * - * @param $model * @param string $operation + or - */ - private function update($model, $operation) + private function update(Countable $model, string $operation): void { - $countCache = new CountCache($model); - $countCache->apply(function ($config) use ($countCache, $model, $operation) { - $countCache->updateCacheRecord($config, $operation, 1, $model->{$config['foreignKey']}); - }); + $countCache = CountCache::for($model); + + $countCache->apply((fn($config) => $this->updateCacheRecord($config->relatedModel($model), $config, $operation))->bindTo($countCache)); } } diff --git a/src/Database/Model.php b/src/Database/Model.php index 13922a3..48f6755 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -1,7 +1,7 @@ data = $this->setupUserAndPost(); } - public function testUserCountCache() + function test_userHasASinglePostCount() { $user = User::first(); $this->assertEquals(1, $user->postCount); - $this->assertEquals(1, $user->postCountExplicit); } - public function testComplexCountCache() + function test_whenRelatedModelsAreSwitchedBothCountCachesAreUpdated() { $post = new Post; $post->userId = $this->data['user']->id; @@ -34,16 +33,14 @@ public function testComplexCountCache() $comment->save(); $this->assertEquals(2, User::first()->postCount); - $this->assertEquals(2, User::first()->postCountExplicit); - $this->assertEquals(1, User::first()->commentCount); $this->assertEquals(1, Post::first()->commentCount); $comment->postId = $post->id; $comment->save(); - $this->assertEquals(0, Post::first()->commentCount); - $this->assertEquals(1, Post::get()[1]->commentCount); + $this->assertEquals(0, $this->data['post']->fresh()->commentCount); + $this->assertEquals(1, $post->fresh()->commentCount); } public function testItCanHandleNegativeCounts() diff --git a/tests/Acceptance/Models/Comment.php b/tests/Acceptance/Models/Comment.php index 5355cb4..ae4a4c7 100644 --- a/tests/Acceptance/Models/Comment.php +++ b/tests/Acceptance/Models/Comment.php @@ -2,21 +2,30 @@ namespace Tests\Acceptance\Models; use Eloquence\Behaviours\CountCache\Countable; -use Eloquence\Behaviours\CamelCasing; +use Eloquence\Behaviours\CountCache\HasCounts; +use Eloquence\Behaviours\CamelCased; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; -class Comment extends Model +class Comment extends Model implements Countable { - use CamelCasing; - use Countable; + use CamelCased; + use HasCounts; use SoftDeletes; - public function countCaches() + public function post(): BelongsTo { - return [ - 'Tests\Acceptance\Models\Post', - 'Tests\Acceptance\Models\User', - ]; + return $this->belongsTo(Post::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function countedBy(): array + { + return ['post', 'user']; } } diff --git a/tests/Acceptance/Models/GuardedUser.php b/tests/Acceptance/Models/GuardedUser.php index a8fd437..e4f308e 100644 --- a/tests/Acceptance/Models/GuardedUser.php +++ b/tests/Acceptance/Models/GuardedUser.php @@ -1,12 +1,12 @@ ['Tests\Acceptance\Models\User', 'userId', 'id'], - [ - 'model' => 'Tests\Acceptance\Models\User', - 'field' => 'postCountExplicit', - 'foreignKey' => 'userId', - 'key' => 'id', - ] - ]; + return $this->belongsTo(User::class); + } + + public function countedBy(): array + { + return ['user']; } public function slugStrategy() diff --git a/tests/Acceptance/Models/User.php b/tests/Acceptance/Models/User.php index 8f94cdd..54e8ed6 100644 --- a/tests/Acceptance/Models/User.php +++ b/tests/Acceptance/Models/User.php @@ -1,13 +1,13 @@ 'Kirk', diff --git a/tests/Unit/Stubs/PivotModelStub.php b/tests/Unit/Stubs/PivotModelStub.php index 4a6edc8..ef36dd4 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;