diff --git a/LICENSE b/LICENSE index 55b6b18..7ad59d1 100644 --- a/LICENSE +++ b/LICENSE @@ -18,5 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 040b51e..a0c2509 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ -# counter-cache +## Counter Cache + +Brings the ruby concept of "counter caching" to [Laravel](http://laravel.com/docs). + +[![Total downloads](https://img.shields.io/packagist/dt/nodes/core.svg)](https://packagist.org/packages/nodes/core) +[![Monthly downloads](ttps://img.shields.io/packagist/dm/nodes/core.svg)](https://packagist.org/packages/nodes/core) +[![Latest release](https://img.shields.io/packagist/v/nodes/core.svg)](https://packagist.org/packages/nodes/core) +[![Open issues](https://img.shields.io/github/issues/nodes-php/core.svg)](https://github.com/nodes-php/core/issues) +[![License](https://img.shields.io/packagist/l/nodes/core.svg)](https://packagist.org/packages/nodes/core) +[![Star repository on GitHub](https://img.shields.io/github/stars/nodes-php/core.svg?style=social&label=Star)](https://github.com/nodes-php/core) +[![Watch repository on GitHub](https://img.shields.io/github/watchers/nodes-php/core.svg?style=social&label=Watch)](https://github.com/nodes-php/core) +[![Fork repository on GitHub](https://img.shields.io/github/forks/nodes-php/core.svg?style=social&label=Fork)](https://github.com/nodes-php/core) + +## Introduction +One thing we at [Nodes](http://nodesagency.com) have been missing in [Laravel](http://laravel.com/docs) is the concept of "counter caching". + +Laravel comes "out of the box" with the [increment](http://laravel.com/docs/5.1/queries#updates)/[decrement](http://laravel.com/docs/5.1/queries#updates) methods on it's [Eloquent](http://laravel.com/docs/5.1/eloquent) models. But you'll need to manually execute these methods everytime, you've saved/delete stuff with your model. + +Since the [increment](http://laravel.com/docs/5.1/queries#updates)/[decrement](http://laravel.com/docs/5.1/queries#updates) methods always +1/-1, you can't 100% rely on these as cached value. +What if you forgot to execute the decrement method when you deleted a row. Or what if someone deleted a row directly from the database, then your count would be "out of sync". + +Therefore we've created this package which brings "counter caching" to [Laravel](http://laravel.com/docs). + +The difference between this package and Laravel's [increment](http://laravel.com/docs/5.1/queries#updates)/[decrement](http://laravel.com/docs/5.1/queries#updates) is that our package actually generates and fires a SQL count statement, that counts the entries and updates the desired column with the result. + +This way you're always 100% sure that the value in your "counter cache" column is correct. + +## Installation + +To install this package you will need: + +* Laravel 5.1+ +* PHP 5.5.9+ + +You must then modify your `composer.json` file and run `composer update` to include the latest version of the package in your project. + +``` +"require": { + "dingo/api": "1.0.*@dev" +} +``` + +Or you can run the composer require command from your terminal. + +``` +composer require nodes/counter-cache +``` + + +## Usage + +To do. + +## Developers / Maintainers + +This package is developed and maintained by the PHP team at [Nodes Agency](http://nodesagency.com) + +[![Follow Nodes PHP on Twitter](https://img.shields.io/twitter/follow/nodesphp.svg?style=social)](https://twitter.com/nodesphp) [![Tweet Nodes PHP](https://img.shields.io/twitter/url/http/nodesphp.svg?style=social)](https://twitter.com/nodesphp) + +### License + +This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT) \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..333c4f8 --- /dev/null +++ b/composer.json @@ -0,0 +1,31 @@ +{ + "name": "nodes/counter-cache", + "description": "Counter caching for Laravel", + "keywords": [ + "nodes", + "counter cache", + "counter-cache", + "laravel", + "database", + "model" + ], + "license": "MIT", + "homepage": "http://nodesagency.com", + "authors": [ + { + "name": "Morten Rugaard", + "email": "moru@nodes.dk", + "role": "Developer" + } + ], + "require": { + "php": ">=5.5.9", + "nodes/core": "^0.1" + }, + "autoload": { + "psr-4": { + "Nodes\\CounterCache\\": "src" + } + }, + "minimum-stability": "stable" +} \ No newline at end of file diff --git a/src/CounterCache.php b/src/CounterCache.php new file mode 100644 index 0000000..1c4869c --- /dev/null +++ b/src/CounterCache.php @@ -0,0 +1,165 @@ + + * + * @access public + * @param \Illuminate\Database\Eloquent\Model $model + * @return boolean + * @throws \Nodes\CounterCache\Exceptions\NoCounterCachesFound + * @throws \Nodes\CounterCache\Exceptions\NotCounterCacheableException + * @throws \Nodes\CounterCache\Exceptions\RelationNotFoundException + */ + public function count(IlluminateModel $model) + { + // If model does not implement the CounterCacheable + // interface, we'll jump ship and abort. + if (!$model instanceof CounterCacheable) { + Log::error(sprintf('[%s] Model [%s] does not implement CounterCacheable.', __CLASS__, get_class($model))); + throw new NotCounterCacheableException(sprintf('Model [%s] does not implement CounterCacheable.', __CLASS__, get_class($model))); + } + + // Retrieve array of available counter caches + $counterCaches = (array) $model->counterCaches(); + + // Validate counter caches + if (empty($counterCaches)) { + Log::error(sprintf('[%s] No counter caches found on model [%s].', __CLASS__, get_class($model))); + throw new NoCounterCachesFound(sprintf('No counter caches found on model [%s].', __CLASS__, get_class($model))); + } + + // Handle each available counter caches + foreach ($counterCaches as $counterCacheColumnName => $relations) { + // Since an available counter cache could be found + // in multiple tables, we'll need to support multiple relations. + foreach ((array) $relations as $relationName => $counterCacheConditions) { + // Sometimes our counter cache might require additional conditions + // which means, we need to support both scenarios + $relationName = !is_array($counterCacheConditions) ? $counterCacheConditions : $relationName; + + // When we've figured out the name of our relation + // we'll just make a quick validation, that it actually exists + if (!method_exists($model, $relationName)) { + Log::error(sprintf('[%s] Relation [%s] was not found on model [%s]', __CLASS__, $relationName, get_class($model))); + throw new RelationNotFoundException(sprintf('Relation [%s] was not found on model [%s]', __CLASS__, $relationName, get_class($model))); + } + + // Retrieve relation query builder + $relation = $model->{$relationName}(); + + // Update the count value for counter cache column + $this->updateCount($model, $relation, $counterCacheConditions, $model->getAttribute($relation->getForeignKey()), $counterCacheColumnName); + + // If our model's foreign key has been updated, + // we need to update the counter cache for the previous value as well + if (!is_null($model->getOriginal($relation->getForeignKey())) && $model->getOriginal($relation->getForeignKey()) != $model->getAttribute($relation->getForeignKey())) { + $this->updateCount($model, $relation, $counterCacheConditions, $model->getOriginal($relation->getForeignKey()), $counterCacheColumnName); + } + } + } + + return true; + } + + /** + * Perform counter caching on all entities of model + * + * @author Morten Rugaard + * + * @access public + * @param \Illuminate\Database\Eloquent\Model $model + * @return boolean + * @throws \Nodes\CounterCache\Exceptions\NoEntitiesFoundException + * @throws \Nodes\CounterCache\Exceptions\NoCounterCachesFound + * @throws \Nodes\CounterCache\Exceptions\NotCounterCacheableException + * @throws \Nodes\CounterCache\Exceptions\RelationNotFoundException + */ + public function countAll(IlluminateModel $model) + { + // Retrieve all entities of model + $entities = $model->get(); + + // If no entities found, we'll log the error, + // throw an exception and abort. + if (!$entities->isEmpty()) { + Log::error(sprintf('[%s] No entities found of model [%s]', __CLASS__, get_class($model))); + throw new NoEntitiesFoundException(sprintf('No entities found of model [%s]', get_class($model))); + } + + // Perform counter caching on each found entity + foreach ($entities as $entry) { + $this->count($entry); + } + + return true; + } + + /** + * Update counter cache column + * + * @author Morten Rugaard + * + * @access protected + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Relations\Relation $relation + * @param array|null $counterCacheConditions + * @param string $foreignKey + * @param string $counterCacheColumnName + * @return boolean + */ + protected function updateCount(IlluminateModel $model, IlluminateRelation $relation, $counterCacheConditions, $foreignKey, $counterCacheColumnName) + { + // Retrieve table name of relation + $relationTableName = $relation->getModel()->getTable(); + + // Generate query builder for counting entries + // on our model. Result will be used as value when + // we're updating the counter cache column on the relation + $countQuery = $model->newQuery() + ->select(DB::raw(sprintf('COUNT(%s.id)', $model->getTable()))) + ->join( + DB::raw(sprintf('(SELECT %s.%s FROM %s) as relation', $relationTableName, $relation->getOtherKey(), $relationTableName)), + sprintf('%s.%s', $model->getTable(), $relation->getForeignKey()), '=', sprintf('relation.%s', $relation->getOtherKey()) + ) + ->where(sprintf('%s.%s', $model->getTable(), $relation->getForeignKey()), '=', $foreignKey); + + // If our relation has additional conditions, we'll need + // to add them to our query builder that counts the entries + if (is_array($counterCacheConditions)) { + foreach ($counterCacheConditions as $conditionType => $conditionParameters) { + foreach ($conditionParameters as $parameters) { + call_user_func_array([$countQuery, $conditionType], $parameters); + } + } + } + + // Retrieve countQuery SQL + // and prepare for binding replacements + $countQuerySql = str_replace(['%', '?'], ['%%', '%s'], $countQuery->toSql()); + + // Fire the update query + // to update counter cache column + return (bool) $relation->getBaseQuery()->update([ + sprintf('%s.%s', $relationTableName, $counterCacheColumnName) => DB::raw(sprintf('(%s)', vsprintf($countQuerySql, $countQuery->getBindings()))) + ]); + } +} \ No newline at end of file diff --git a/src/CounterCacheable.php b/src/CounterCacheable.php new file mode 100644 index 0000000..f1a0075 --- /dev/null +++ b/src/CounterCacheable.php @@ -0,0 +1,21 @@ + + * + * @access public + * @return array + */ + public function counterCaches(); +} \ No newline at end of file diff --git a/src/Exceptions/CounterCacheException.php b/src/Exceptions/CounterCacheException.php new file mode 100644 index 0000000..91d85fa --- /dev/null +++ b/src/Exceptions/CounterCacheException.php @@ -0,0 +1,29 @@ + + * + * @access public + * @param string $message + * @param integer $statusCode + * @param string|null $statusMessage + * @param array $headers + * @param boolean $report + */ + public function __construct($message = 'Counter cache failed', $statusCode = 500, $statusMessage = 'Counter cache failed', array $headers = [], $report = true) + { + parent::__construct($message, $statusCode, $statusMessage, $headers, $report); + } +} \ No newline at end of file diff --git a/src/Exceptions/NoCounterCachesFoundException.php b/src/Exceptions/NoCounterCachesFoundException.php new file mode 100644 index 0000000..ea2d728 --- /dev/null +++ b/src/Exceptions/NoCounterCachesFoundException.php @@ -0,0 +1,27 @@ + + * + * @access public + * @param string $message + * @param integer $statusCode + * @param string|null $statusMessage + * @param array $headers + * @param boolean $report + */ + public function __construct($message = 'No counter caches found on model', $statusCode = 500, $statusMessage = 'Counter cache failed', array $headers = [], $report = true) + { + parent::__construct($message, $statusCode, $statusMessage, $headers, $report); + } +} \ No newline at end of file diff --git a/src/Exceptions/NoEntitiesFoundException.php b/src/Exceptions/NoEntitiesFoundException.php new file mode 100644 index 0000000..f59638f --- /dev/null +++ b/src/Exceptions/NoEntitiesFoundException.php @@ -0,0 +1,27 @@ + + * + * @access public + * @param string $message + * @param integer $statusCode + * @param string|null $statusMessage + * @param array $headers + * @param boolean $report + */ + public function __construct($message = 'No entities found', $statusCode = 500, $statusMessage = 'Counter cache failed', array $headers = [], $report = true) + { + parent::__construct($message, $statusCode, $statusMessage, $headers, $report); + } +} \ No newline at end of file diff --git a/src/Exceptions/NotCounterCacheableException.php b/src/Exceptions/NotCounterCacheableException.php new file mode 100644 index 0000000..9709c7b --- /dev/null +++ b/src/Exceptions/NotCounterCacheableException.php @@ -0,0 +1,27 @@ + + * + * @access public + * @param string $message + * @param integer $statusCode + * @param string|null $statusMessage + * @param array $headers + * @param boolean $report + */ + public function __construct($message = 'Model does not implement CounterCacheable', $statusCode = 500, $statusMessage = 'Counter cache failed', array $headers = [], $report = true) + { + parent::__construct($message, $statusCode, $statusMessage, $headers, $report); + } +} \ No newline at end of file diff --git a/src/Exceptions/RelationNotFoundException.php b/src/Exceptions/RelationNotFoundException.php new file mode 100644 index 0000000..c34f849 --- /dev/null +++ b/src/Exceptions/RelationNotFoundException.php @@ -0,0 +1,27 @@ + + * + * @access public + * @param string $message + * @param integer $statusCode + * @param string|null $statusMessage + * @param array $headers + * @param boolean $report + */ + public function __construct($message = 'Relation not found on model', $statusCode = 500, $statusMessage = 'Counter cache failed', array $headers = [], $report = true) + { + parent::__construct($message, $statusCode, $statusMessage, $headers, $report); + } +} \ No newline at end of file diff --git a/src/Traits/CounterCache.php b/src/Traits/CounterCache.php new file mode 100644 index 0000000..324c3cf --- /dev/null +++ b/src/Traits/CounterCache.php @@ -0,0 +1,15 @@ + + * + * @static + * @return void + */ + public static function bootCounterCacheCreated() + { + static::created(function($model) { + app('Nodes\CounterCache\CounterCache')->count($model); + }); + } +} \ No newline at end of file diff --git a/src/Traits/CounterCacheDeleted.php b/src/Traits/CounterCacheDeleted.php new file mode 100644 index 0000000..9ec4602 --- /dev/null +++ b/src/Traits/CounterCacheDeleted.php @@ -0,0 +1,26 @@ + + * + * @static + * @return void + */ + public static function bootCounterCacheDeleted() + { + static::deleted(function($model) { + app('Nodes\CounterCache\CounterCache')->count($model); + }); + } +} \ No newline at end of file diff --git a/src/Traits/CounterCacheRestored.php b/src/Traits/CounterCacheRestored.php new file mode 100644 index 0000000..6b13b42 --- /dev/null +++ b/src/Traits/CounterCacheRestored.php @@ -0,0 +1,26 @@ + + * + * @static + * @return void + */ + public static function bootCounterCacheRestored() + { + static::restored(function($model) { + app('Nodes\CounterCache\CounterCache')->count($model); + }); + } +} \ No newline at end of file diff --git a/src/Traits/CounterCacheSaved.php b/src/Traits/CounterCacheSaved.php new file mode 100644 index 0000000..cca20c6 --- /dev/null +++ b/src/Traits/CounterCacheSaved.php @@ -0,0 +1,26 @@ + + * + * @static + * @return void + */ + public static function bootCounterCacheSaved() + { + static::saved(function($model) { + app('Nodes\CounterCache\CounterCache')->count($model); + }); + } +} \ No newline at end of file diff --git a/src/Traits/CounterCacheUpdated.php b/src/Traits/CounterCacheUpdated.php new file mode 100644 index 0000000..cbae553 --- /dev/null +++ b/src/Traits/CounterCacheUpdated.php @@ -0,0 +1,26 @@ + + * + * @static + * @return void + */ + public static function bootCounterCacheUpdated() + { + static::updated(function($model) { + app('Nodes\CounterCache\CounterCache')->count($model); + }); + } +} \ No newline at end of file