From a500d4d61e46ce4637168386a9fe7fe40a9e0d76 Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Sun, 27 Sep 2020 17:09:59 +0200 Subject: [PATCH] Added validation command; Fixed bug in calculate command; Added tests; Added TravisCI, Codecoverage and StyleCI (#1) * Added validation command * Fixed bug in calculate command * Added tests * Added TravisCI, codecoverage and StyleCI * Enhanced readme --- .gitignore | 4 + .php_cs | 14 ++ .styleci.yml | 1 + .travis.yml | 76 ++++++++++ composer.json | 26 +++- config/computed-attributes.php | 6 + docker/Dockerfile | 24 ++++ docker/docker-compose.yml | 9 ++ phpcs.xml | 9 ++ phpunit.xml | 38 +++++ phpunit.xml.old | 36 +++++ readme.md | 43 +++++- src/ComputedAttributes.php | 46 +++++- src/Console/GenerateComputedAttributes.php | 136 ++++++------------ src/Console/ValidateComputedAttributes.php | 116 +++++++++++++++ ...ravelComputedAttributesServiceProvider.php | 15 +- src/Parser/ModelAttributeParser.php | 108 ++++++++++++++ src/Parser/ModelAttributesEntry.php | 43 ++++++ src/Parser/ParsingException.php | 9 ++ .../GenerateComputedAttributesCommandTest.php | 124 ++++++++++++++++ .../ValidateComputedAttributesCommandTest.php | 133 +++++++++++++++++ tests/Models/Post.php | 35 ----- tests/TestCase.php | 33 +++++ .../0000_00_00_000000_add_posts_table.php | 36 +++++ .../0000_00_00_000001_add_votes_table.php | 39 +++++ tests/TestEnvironment/Models/Post.php | 113 +++++++++++++++ tests/TestEnvironment/Models/Vote.php | 44 ++++++ 27 files changed, 1174 insertions(+), 142 deletions(-) create mode 100644 .php_cs create mode 100644 .styleci.yml create mode 100644 .travis.yml create mode 100644 config/computed-attributes.php create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 phpcs.xml create mode 100644 phpunit.xml create mode 100644 phpunit.xml.old create mode 100644 src/Console/ValidateComputedAttributes.php create mode 100644 src/Parser/ModelAttributeParser.php create mode 100644 src/Parser/ModelAttributesEntry.php create mode 100644 src/Parser/ParsingException.php create mode 100644 tests/Feature/GenerateComputedAttributesCommandTest.php create mode 100644 tests/Feature/ValidateComputedAttributesCommandTest.php delete mode 100644 tests/Models/Post.php create mode 100644 tests/TestCase.php create mode 100644 tests/TestEnvironment/Migrations/0000_00_00_000000_add_posts_table.php create mode 100644 tests/TestEnvironment/Migrations/0000_00_00_000001_add_votes_table.php create mode 100644 tests/TestEnvironment/Models/Post.php create mode 100644 tests/TestEnvironment/Models/Vote.php diff --git a/.gitignore b/.gitignore index 2ab3bab..04db3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ .Trashes ehthumbs.db Thumbs.db +vendor +composer.lock +.php_cs.cache +.phpunit.result.cache diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..398a1a5 --- /dev/null +++ b/.php_cs @@ -0,0 +1,14 @@ +setRiskyAllowed(false) + ->setRules([ + '@PSR2' => true, + ]) + ->setUsingCache(true) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__.'/src') + ->in(__DIR__.'/tests') + ) +; diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..0285f17 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1 @@ +preset: laravel diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a836213 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,76 @@ +cache: + directories: + - $HOME/.composer/cache + +language: php + +matrix: + include: + # Laravel 5.7.* + - php: 7.1 + env: LARAVEL='5.7.*' TESTBENCH='3.7.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.2 + env: LARAVEL='5.7.*' TESTBENCH='3.7.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.3 + env: LARAVEL='5.7.*' TESTBENCH='3.7.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + # Laravel 5.8.* + - php: 7.1 + env: LARAVEL='5.8.*' TESTBENCH='3.8.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.1 + env: LARAVEL='5.8.*' TESTBENCH='3.8.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.2 + env: LARAVEL='5.8.*' TESTBENCH='3.8.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.2 + env: LARAVEL='5.8.*' TESTBENCH='3.8.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.3 + env: LARAVEL='5.8.*' TESTBENCH='3.8.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.3 + env: LARAVEL='5.8.*' TESTBENCH='3.8.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + # Laravel 6.* + - php: 7.2 + env: LARAVEL='6.*' TESTBENCH='4.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.2 + env: LARAVEL='6.*' TESTBENCH='4.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.3 + env: LARAVEL='6.*' TESTBENCH='4.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml' + - php: 7.3 + env: LARAVEL='6.*' TESTBENCH='4.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml' + # Laravel 7.* + - php: 7.2 + env: LARAVEL='7.*' TESTBENCH='5.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.2 + env: LARAVEL='7.*' TESTBENCH='5.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml.old' + - php: 7.3 + env: LARAVEL='7.*' TESTBENCH='5.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml' + - php: 7.3 + env: LARAVEL='7.*' TESTBENCH='5.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml' + - php: 7.4 + env: LARAVEL='7.*' TESTBENCH='5.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml' + - php: 7.4 + env: LARAVEL='7.*' TESTBENCH='5.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml' + # Laravel 8.* + - php: 7.3 + env: LARAVEL='8.*' TESTBENCH='6.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml' + - php: 7.3 + env: LARAVEL='8.*' TESTBENCH='6.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml' + - php: 7.4 + env: LARAVEL='8.*' TESTBENCH='6.*' COMPOSER_FLAGS='--prefer-lowest' PHP_UNIT_CONFIG='phpunit.xml' + - php: 7.4 + env: LARAVEL='8.*' TESTBENCH='6.*' COMPOSER_FLAGS='--prefer-stable' PHP_UNIT_CONFIG='phpunit.xml' + fast_finish: true + +before_install: + - travis_retry composer self-update + - travis_retry composer require "laravel/framework:${LARAVEL}" "orchestra/testbench:${TESTBENCH}" --no-interaction --no-update + +install: + - travis_retry composer update ${COMPOSER_FLAGS} --prefer-dist --no-interaction --no-suggest + +before_script: + - composer config discard-changes true + +script: + - vendor/bin/phpunit -c ${PHP_UNIT_CONFIG} --coverage-text --coverage-clover=coverage.xml + +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/composer.json b/composer.json index e2d66d7..bf7c0e8 100644 --- a/composer.json +++ b/composer.json @@ -12,16 +12,34 @@ ], "minimum-stability": "stable", "require": { - "php": "^7.1|^7.2|^7.3|^7.4", - "illuminate/support": "^5.6|^5.7|^5.8|^6|^7", - "illuminate/database": "^5.6|^5.7|^5.8|^6|^7", - "illuminate/console": "^5.6|^5.7|^5.8|^6|^7" + "php": "^7.1", + "composer/composer": "^1.10", + "illuminate/console": "^5.7|^6|^7|^8", + "illuminate/database": "^5.7|^6|^7|^8", + "illuminate/support": "^5.7|^6|^7|^8" + }, + "require-dev": { + "orchestra/testbench": "^3.6|^4.0|^5.0|^6.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0", + "friendsofphp/php-cs-fixer": "^2.16", + "squizlabs/php_codesniffer": "^3.5" }, "autoload": { "psr-4": { "Korridor\\LaravelComputedAttributes\\": "src" } }, + "autoload-dev": { + "psr-4": { + "Korridor\\LaravelComputedAttributes\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage", + "fix": "./vendor/bin/php-cs-fixer fix", + "lint": "./vendor/bin/phpcs --error-severity=1 --warning-severity=8 --extensions=php" + }, "extra": { "laravel": { "providers": [ diff --git a/config/computed-attributes.php b/config/computed-attributes.php new file mode 100644 index 0000000..d5e1732 --- /dev/null +++ b/config/computed-attributes.php @@ -0,0 +1,6 @@ + 'app/Models', + 'model_namespace' => 'App\\Models', +]; diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..7dc8b96 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,24 @@ +FROM php:7.4-cli + +RUN apt-get update && apt-get install -y \ + zlib1g-dev \ + libzip-dev + +RUN docker-php-ext-install zip + +RUN pecl install xdebug-2.8.1 \ + && docker-php-ext-enable xdebug + +# Install composer and add its bin to the PATH. +RUN curl -s http://getcomposer.org/installer | php && \ + echo "export PATH=${PATH}:/var/www/vendor/bin" >> ~/.bashrc && \ + mv composer.phar /usr/local/bin/composer + +# Add bash aliases +RUN echo "alias ll='ls --color=auto -al'" >> ~/.bashrc + + +# Source the bash +RUN . ~/.bashrc + +WORKDIR /usr/src/app diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..dd54d6e --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.7' +services: + workspace: + build: + context: . + volumes: + - ..:/usr/src/app + tty: true + stdin_open: true diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..4f5006d --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,9 @@ + + + The ASH2 coding standard. + + + + src/ + tests/ + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e312d86 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,38 @@ + + + + + src/ + + + + + tests/Feature + + + + + + + + + + + + + + + + + diff --git a/phpunit.xml.old b/phpunit.xml.old new file mode 100644 index 0000000..e73bb3e --- /dev/null +++ b/phpunit.xml.old @@ -0,0 +1,36 @@ + + + + + tests/Feature + + + + + src/ + + + + + + + + + + + + + + + + + diff --git a/readme.md b/readme.md index e4fa174..2629383 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,16 @@ # Laravel computed attributes -Warning: This package is still under heavy development. +[![Latest Version on Packagist](https://img.shields.io/packagist/v/korridor/laravel-computed-attributes?style=flat-square)](https://packagist.org/packages/korridor/laravel-computed-attributes) +[![License](https://img.shields.io/packagist/l/korridor/laravel-computed-attributes?style=flat-square)](license.md) +[![TravisCI](https://img.shields.io/travis/korridor/laravel-computed-attributes?style=flat-square)](https://travis-ci.org/korridor/laravel-computed-attributes) +[![Codecov](https://img.shields.io/codecov/c/github/korridor/laravel-computed-attributes?style=flat-square)](https://codecov.io/gh/korridor/laravel-computed-attributes) +[![StyleCI](https://styleci.io/repos/226346821/shield)](https://styleci.io/repos/226346821) +Laravel package that adds computed attributes to eloquent models. +A computed attribute is an accessor where the value is saved in the database. +The value can be regenerated or validated at any time. +This can increase performance (no calculation at every get/fetch) and it can simplify querying the database (f.e. complex filter system). +`` ## Installation You can install the package via composer with following command: @@ -10,6 +19,38 @@ You can install the package via composer with following command: composer require korridor/laravel-computed-attributes ``` +### Requirements + +This package is tested for the following Laravel versions: + + - 8.* + - 7.* + - 6.* + - 5.8.* + - 5.7.* (stable only) + +## Usage examples + +See folder `tests/TestEnvironment`. + +## Contributing + +I am open for suggestions and contributions. Just create an issue or a pull request. + +### Testing + +```bash +composer test +composer test-coverage +``` + +### Codeformatting/Linting + +```bash +composer fix +composer lint +``` + ## License This package is licensed under the MIT License (MIT). Please see [license file](license.md) for more information. diff --git a/src/ComputedAttributes.php b/src/ComputedAttributes.php index de60c93..9989a65 100644 --- a/src/ComputedAttributes.php +++ b/src/ComputedAttributes.php @@ -2,35 +2,71 @@ namespace Korridor\LaravelComputedAttributes; -use Str; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; +/** + * @method static Builder|Model computedAttributesValidate(array $attributes) + * @method static Builder|Model computedAttributesGenerate(array $attributes) + */ trait ComputedAttributes { /** + * Compute the given attribute and return the result. + * * @param string $attributeName * @return mixed */ public function getComputedAttributeValue(string $attributeName) { $functionName = 'get'.Str::studly($attributeName).'Computed'; - $value = $this->{$functionName}(); - return $value; + return $this->{$functionName}(); } /** + * Compute the given attribute and assign the result in the model. + * * @param string $attributeName */ - public function setComputedAttributeValue(string $attributeName) + public function setComputedAttributeValue(string $attributeName): void { $computed = $this->getComputedAttributeValue($attributeName); $this->{$attributeName} = $computed; } /** + * This scope will be applied during the computed property generation with artisan computed-attributes:generate. + * + * @param Builder $builder + * @param array $attributes Attributes that will be generated. + * @return Builder + */ + public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder + { + return $builder; + } + + /** + * This scope will be applied during the computed property validation with artisan computed-attributes:validate. + * + * @param Builder $builder + * @param array $attributes Attributes that will be validated. + * @return Builder + */ + public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder + { + return $builder; + } + + /** + * Return the configuration array for this model. + * If the configuration array does not exist the function will return an empty array. + * * @return array */ - public function getComputedAttributeConfiguration() + public function getComputedAttributeConfiguration(): array { if (isset($this->computed)) { return $this->computed; diff --git a/src/Console/GenerateComputedAttributes.php b/src/Console/GenerateComputedAttributes.php index c57ddc5..4af9dc3 100644 --- a/src/Console/GenerateComputedAttributes.php +++ b/src/Console/GenerateComputedAttributes.php @@ -2,11 +2,12 @@ namespace Korridor\LaravelComputedAttributes\Console; -use Composer\Autoload\ClassMapGenerator; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Korridor\LaravelComputedAttributes\ComputedAttributes; -use ReflectionClass; +use Korridor\LaravelComputedAttributes\Parser\ModelAttributeParser; +use Korridor\LaravelComputedAttributes\Parser\ParsingException; use ReflectionException; /** @@ -20,135 +21,78 @@ class GenerateComputedAttributes extends Command * @var string */ protected $signature = 'computed-attributes:generate '. - '{modelsAttributes? : List of models and optionally their attributes (example: "FullModel;PartModel:attribute_1,attribute_2" or "OtherNamespace\OtherModel")} '. - '{--chunkSize=100 : Size of the model chunk}'; + '{modelsAttributes? : List of models and optionally their attributes, '. + 'if not given all models that use the ComputedAttributes trait '. + '(example: "FullModel;PartModel:attribute_1,attribute_2" or "OtherNamespace\OtherModel")} '. + '{--chunkSize=500 : Size of the model chunk}'; /** * The console command description. * * @var string */ - protected $description = ''; - - /** - * Create a new command instance. - */ - public function __construct() - { - parent::__construct(); - } + protected $description = '(Re-)generates and saves the given computed attributes.'; /** * Execute the console command. * - * @return bool + * @return int * @throws ReflectionException */ public function handle() { $modelsWithAttributes = $this->argument('modelsAttributes'); - $modelPath = app_path('Models'); - $modelNamespace = 'App\\Models\\'; + + $this->info('Parsing arguments...'); + + // Validate chunkSize option $chunkSizeRaw = $this->option('chunkSize'); if (preg_match('/^\d+$/', $chunkSizeRaw)) { $chunkSize = intval($chunkSizeRaw); if ($chunkSize < 1) { $this->error('Option chunkSize needs to be greater than zero'); - return false; + return 1; } } else { - $this->error('Option chunkSize needs to be an integer'); + $this->error('Option chunkSize needs to be an integer greater than zero'); - return false; + return 1; } - // Get all models with trait - $classmap = ClassMapGenerator::createMap($modelPath); - $models = []; - foreach ($classmap as $class => $filepath) { - $reflection = new ReflectionClass($class); - $traits = $reflection->getTraitNames(); - foreach ($traits as $trait) { - if ('Korridor\\LaravelComputedAttributes\\ComputedAttributes' === $trait) { - array_push($models, $class); - } - } - } + // Validate and parse modelsAttributes argument + $modelAttributeParser = app(ModelAttributeParser::class); + try { + $modelAttributesEntries = $modelAttributeParser->getModelAttributeEntries($modelsWithAttributes); + } catch (ParsingException $exception) { + $this->error($exception->getMessage()); - // Get all class/attribute combinations - $modelAttributesToProcess = []; - if (null === $modelsWithAttributes) { - $this->info('Start calculating for all models with trait...'); - foreach ($models as $model) { - /** @var Model|ComputedAttributes $modelInstance */ - $modelInstance = new $model(); - $attributes = $modelInstance->getComputedAttributeConfiguration(); - array_push($modelAttributesToProcess, [ - 'model' => $model, - 'modelInstance' => $modelInstance, - 'attributes' => $attributes, - ]); - } - } else { - $this->info('Start calculating for given models...'); - $modelsInAttribute = explode(';', $modelsWithAttributes); - foreach ($modelsInAttribute as $modelInAttribute) { - $modelInAttributeExploded = explode(':', $modelInAttribute); - if (1 !== sizeof($modelInAttributeExploded) && 2 !== sizeof($modelInAttributeExploded)) { - $this->error('Parsing error'); - - return false; - } - $model = $modelNamespace.$modelInAttributeExploded[0]; - if (in_array($model, $models)) { - /** @var Model|ComputedAttributes $modelInstance */ - $modelInstance = new $model(); - } else { - $this->error('Model "'.$model.'" not found'); - - return false; - } - $attributes = $modelInstance->getComputedAttributeConfiguration(); - if (2 === sizeof($modelInAttributeExploded)) { - $attributeWhitelistItems = explode(',', $modelInAttributeExploded[1]); - foreach ($attributeWhitelistItems as $attributeWhitelistItem) { - if (in_array($attributeWhitelistItem, $attributes)) { - } else { - $this->error('Attribute "'.$attributeWhitelistItem.'" does not exist in model '.$model); - - return false; - } - } - } - array_push($modelAttributesToProcess, [ - 'model' => $model, - 'modelInstance' => $modelInstance, - 'attributes' => $attributes, - ]); - } + return 1; } // Calculate - foreach ($modelAttributesToProcess as $modelAttributeToProcess) { - $this->info('Start calculating for following attributes of model "'.$modelAttributeToProcess['model'].'":'); - /** @var Model|ComputedAttributes $modelInstance */ - $modelInstance = $modelAttributeToProcess['modelInstance']; - $attributes = $modelAttributeToProcess['attributes']; + foreach ($modelAttributesEntries as $modelAttributesEntry) { + $model = $modelAttributesEntry->getModel(); + /** @var Builder|ComputedAttributes $modelInstance */ + $modelInstance = new $model(); + $attributes = $modelAttributesEntry->getAttributes(); + + $this->info('Start calculating for following attributes of model "'.$model.'":'); $this->info('['.implode(',', $attributes).']'); if (sizeof($attributes) > 0) { - $modelInstance->chunk($chunkSize, function ($modelResults) use ($attributes) { - /* @var Model|ComputedAttributes $modelInstance */ - foreach ($modelResults as $modelResult) { - foreach ($attributes as $attribute) { - $modelResult->setComputedAttributeValue($attribute); + $modelInstance->computedAttributesGenerate($attributes) + ->chunk($chunkSize, function ($modelResults) use ($attributes) { + /* @var Model|ComputedAttributes $modelResult */ + foreach ($modelResults as $modelResult) { + foreach ($attributes as $attribute) { + $modelResult->setComputedAttributeValue($attribute); + } + $modelResult->save(); } - $modelResult->save(); - } - }); + }); } } - return true; + return 0; } } diff --git a/src/Console/ValidateComputedAttributes.php b/src/Console/ValidateComputedAttributes.php new file mode 100644 index 0000000..12187b7 --- /dev/null +++ b/src/Console/ValidateComputedAttributes.php @@ -0,0 +1,116 @@ +argument('modelsAttributes'); + + $this->info('Parsing arguments...'); + + // Validate chunkSize option + $chunkSizeRaw = $this->option('chunkSize'); + if (preg_match('/^\d+$/', $chunkSizeRaw)) { + $chunkSize = intval($chunkSizeRaw); + if ($chunkSize < 1) { + $this->error('Option chunkSize needs to be greater than zero'); + + return 1; + } + } else { + $this->error('Option chunkSize needs to be an integer greater than zero'); + + return 1; + } + + // Validate and parse modelsAttributes argument + $modelAttributeParser = app(ModelAttributeParser::class); + try { + $modelAttributesEntries = $modelAttributeParser->getModelAttributeEntries($modelsWithAttributes); + } catch (ParsingException $exception) { + $this->error($exception->getMessage()); + + return 1; + } + + // Validate + foreach ($modelAttributesEntries as $modelAttributesEntry) { + $model = $modelAttributesEntry->getModel(); + /** @var Builder|ComputedAttributes $modelInstance */ + $modelInstance = new $model(); + $attributes = $modelAttributesEntry->getAttributes(); + + $this->info('Start validating following attributes of model "'.$model.'":'); + $this->info('['.implode(',', $attributes).']'); + if (sizeof($attributes) > 0) { + $modelInstance->computedAttributesValidate($attributes) + ->chunk($chunkSize, function ($modelResults) use ($attributes, $modelAttributesEntry) { + /* @var Model|ComputedAttributes $modelResult */ + foreach ($modelResults as $modelResult) { + foreach ($attributes as $attribute) { + if ($modelResult->getComputedAttributeValue($attribute) !== $modelResult->{$attribute}) { + $this->info($modelAttributesEntry->getModel(). + '['.$modelResult->getKeyName().'='.$modelResult->getKey().']['.$attribute.']'); + $this->info('Current value: '.$this->varToString($modelResult->{$attribute})); + $this->info('Calculated value: '. + $this->varToString($modelResult->getComputedAttributeValue($attribute))); + } + } + } + }); + } + } + + return 0; + } + + /** + * @param $var + * @return false|string + */ + private function varToString($var) + { + if ($var === null) { + return 'null'; + } + + return gettype($var).'('.$var.')'; + } +} diff --git a/src/LaravelComputedAttributesServiceProvider.php b/src/LaravelComputedAttributesServiceProvider.php index 00cdb05..5d56e70 100644 --- a/src/LaravelComputedAttributesServiceProvider.php +++ b/src/LaravelComputedAttributesServiceProvider.php @@ -3,7 +3,7 @@ namespace Korridor\LaravelComputedAttributes; use Illuminate\Support\ServiceProvider; -use Korridor\LaravelComputedAttributes\Console\GenerateComputedAttributes; +use Korridor\LaravelComputedAttributes\Parser\ModelAttributeParser; /** * Class LaravelComputedAttributesServiceProvider. @@ -23,9 +23,22 @@ public function register() public function boot() { if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/computed-attributes.php' => config_path('computed-attributes.php'), + ], 'computed-attributes'); $this->commands([ Console\GenerateComputedAttributes::class, + Console\ValidateComputedAttributes::class, ]); } + $this->app->bind(ModelAttributeParser::class, function () { + return new ModelAttributeParser(); + }); + if (! $this->app->configurationIsCached()) { + $this->mergeConfigFrom( + __DIR__.'/../config/computed-attributes.php', + 'computed-attributes' + ); + } } } diff --git a/src/Parser/ModelAttributeParser.php b/src/Parser/ModelAttributeParser.php new file mode 100644 index 0000000..a0f38e5 --- /dev/null +++ b/src/Parser/ModelAttributeParser.php @@ -0,0 +1,108 @@ +getAbsolutePathOfModelFolder()); + $models = []; + foreach ($classmap as $class => $filepath) { + $reflection = new ReflectionClass($class); + $traits = $reflection->getTraitNames(); + foreach ($traits as $trait) { + if ('Korridor\\LaravelComputedAttributes\\ComputedAttributes' === $trait) { + array_push($models, $class); + } + } + } + + return $models; + } + + /** + * @param string|null $modelsWithAttributes + * @return ModelAttributesEntry[] + * @throws ParsingException + * @throws ReflectionException + */ + public function getModelAttributeEntries(?string $modelsWithAttributes = null) + { + $modelAttributesToProcess = []; + $models = $this->getAllModelClasses(); + if (null === $modelsWithAttributes) { + foreach ($models as $model) { + /** @var Model|ComputedAttributes $modelInstance */ + $modelInstance = new $model(); + $attributes = $modelInstance->getComputedAttributeConfiguration(); + array_push($modelAttributesToProcess, new ModelAttributesEntry($model, $attributes)); + } + } else { + $modelsInAttribute = explode(';', $modelsWithAttributes); + foreach ($modelsInAttribute as $modelInAttribute) { + $modelInAttributeExploded = explode(':', $modelInAttribute); + if (1 !== sizeof($modelInAttributeExploded) && 2 !== sizeof($modelInAttributeExploded)) { + throw new ParsingException('Parsing error'); + } + $model = $this->getModelNamespaceBase().str_replace('/', '\\', $modelInAttributeExploded[0]); + if (in_array($model, $models)) { + /** @var Model|ComputedAttributes $modelInstance */ + $modelInstance = new $model(); + } else { + throw new ParsingException('Model "'.$model.'" not found'); + } + $attributes = $modelInstance->getComputedAttributeConfiguration(); + if (2 === sizeof($modelInAttributeExploded)) { + $attributeWhitelistItems = explode(',', $modelInAttributeExploded[1]); + foreach ($attributeWhitelistItems as $attributeWhitelistItem) { + if (! in_array($attributeWhitelistItem, $attributes)) { + throw new ParsingException('Attribute "'.$attributeWhitelistItem. + '" does not exist in model '.$model); + } + } + $attributes = $attributeWhitelistItems; + } + array_push($modelAttributesToProcess, new ModelAttributesEntry($model, $attributes)); + } + } + + return $modelAttributesToProcess; + } +} diff --git a/src/Parser/ModelAttributesEntry.php b/src/Parser/ModelAttributesEntry.php new file mode 100644 index 0000000..72eaccc --- /dev/null +++ b/src/Parser/ModelAttributesEntry.php @@ -0,0 +1,43 @@ +model = $model; + $this->attributes = $attributes; + } + + /** + * @return string + */ + public function getModel(): string + { + return $this->model; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/src/Parser/ParsingException.php b/src/Parser/ParsingException.php new file mode 100644 index 0000000..9f8b1ce --- /dev/null +++ b/src/Parser/ParsingException.php @@ -0,0 +1,9 @@ +title = 'titleTest'; + $post->content = 'Text'; + $post->save(); + $vote = new Vote(); + $vote->rating = 4; + $vote->post()->associate($post); + $vote->save(); + Config::set('computed-attributes.model_path', 'Models'); + Config::set( + 'computed-attributes.model_namespace', + 'Korridor\\LaravelComputedAttributes\\Tests\\TestEnvironment\\Models' + ); + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => null, + 'sum_of_votes' => 0, + ]); + + // Act + $this->artisan('computed-attributes:generate', [ + 'modelsAttributes' => null, + ]) + ->expectsOutput('Start calculating for following attributes of model '. + '"Korridor\LaravelComputedAttributes\Tests\TestEnvironment\Models\Post":') + ->expectsOutput('[complex_calculation,sum_of_votes]') + ->assertExitCode(0) + ->execute(); + + // Assert + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => 3, + 'sum_of_votes' => 4, + ]); + } + + public function testCommandCanOnlyCalculateOneAttributeOfOneModelIfSpecifiedInArgument() + { + // Arrange + $post = new Post(); + $post->title = 'titleTest'; + $post->content = 'Text'; + $post->save(); + $vote = new Vote(); + $vote->rating = 4; + $vote->post()->associate($post); + $vote->save(); + Config::set('computed-attributes.model_path', 'Models'); + Config::set( + 'computed-attributes.model_namespace', + 'Korridor\\LaravelComputedAttributes\\Tests\\TestEnvironment\\Models' + ); + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => null, + 'sum_of_votes' => 0, + ]); + + // Act + $this->artisan('computed-attributes:generate', [ + 'modelsAttributes' => 'Post:sum_of_votes', + ]) + ->expectsOutput('Start calculating for following attributes of model '. + '"Korridor\LaravelComputedAttributes\Tests\TestEnvironment\Models\Post":') + ->expectsOutput('[sum_of_votes]') + ->assertExitCode(0) + ->execute(); + + // Assert + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => null, + 'sum_of_votes' => 4, + ]); + } + + public function testNonNumericChunkSizeIsReturnsErrorMessage() + { + $this->artisan('computed-attributes:generate', [ + '--chunkSize' => 'text', + ]) + ->expectsOutput('Option chunkSize needs to be an integer greater than zero') + ->assertExitCode(1) + ->execute(); + } + + public function testNegativeChunkSizeReturnsErrorMessage() + { + $this->artisan('computed-attributes:generate', [ + '--chunkSize' => '-10', + ]) + ->expectsOutput('Option chunkSize needs to be an integer greater than zero') + ->assertExitCode(1) + ->execute(); + } + + public function testZeroAsChunkSizeReturnsErrorMessage() + { + $this->artisan('computed-attributes:generate', [ + '--chunkSize' => '0', + ]) + ->expectsOutput('Option chunkSize needs to be greater than zero') + ->assertExitCode(1) + ->execute(); + } +} diff --git a/tests/Feature/ValidateComputedAttributesCommandTest.php b/tests/Feature/ValidateComputedAttributesCommandTest.php new file mode 100644 index 0000000..632d7cb --- /dev/null +++ b/tests/Feature/ValidateComputedAttributesCommandTest.php @@ -0,0 +1,133 @@ +title = 'titleTest'; + $post->content = 'Text'; + $post->save(); + $vote = new Vote(); + $vote->rating = 4; + $vote->post()->associate($post); + $vote->save(); + Config::set('computed-attributes.model_path', 'Models'); + Config::set( + 'computed-attributes.model_namespace', + 'Korridor\\LaravelComputedAttributes\\Tests\\TestEnvironment\\Models' + ); + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => null, + 'sum_of_votes' => 0, + ]); + + // Act + $this->artisan('computed-attributes:validate', [ + 'modelsAttributes' => null, + ]) + ->expectsOutput('Start validating following attributes of model '. + '"Korridor\LaravelComputedAttributes\Tests\TestEnvironment\Models\Post":') + ->expectsOutput('[complex_calculation,sum_of_votes]') + ->expectsOutput('Korridor\LaravelComputedAttributes\Tests\TestEnvironment\Models\Post[id=1][complex_calculation]') + ->expectsOutput('Current value: null') + ->expectsOutput('Calculated value: integer(3)') + ->expectsOutput('Korridor\LaravelComputedAttributes\Tests\TestEnvironment\Models\Post[id=1][sum_of_votes]') + ->expectsOutput('Current value: integer(0)') + ->expectsOutput('Calculated value: integer(4)') + ->assertExitCode(0) + ->execute(); + + // Assert + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => null, + 'sum_of_votes' => 0, + ]); + } + + public function testCommandCanOnlyCalculateOneAttributeOfOneModelIfSpecifiedInArgument() + { + // Arrange + $post = new Post(); + $post->title = 'titleTest'; + $post->content = 'Text'; + $post->save(); + $vote = new Vote(); + $vote->rating = 4; + $vote->post()->associate($post); + $vote->save(); + Config::set('computed-attributes.model_path', 'Models'); + Config::set( + 'computed-attributes.model_namespace', + 'Korridor\\LaravelComputedAttributes\\Tests\\TestEnvironment\\Models' + ); + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => null, + 'sum_of_votes' => 0, + ]); + + // Act + $this->artisan('computed-attributes:validate', [ + 'modelsAttributes' => 'Post:sum_of_votes', + ]) + ->expectsOutput('Start validating following attributes of model '. + '"Korridor\LaravelComputedAttributes\Tests\TestEnvironment\Models\Post":') + ->expectsOutput('[sum_of_votes]') + ->expectsOutput('Korridor\LaravelComputedAttributes\Tests\TestEnvironment\Models\Post[id=1][sum_of_votes]') + ->expectsOutput('Current value: integer(0)') + ->expectsOutput('Calculated value: integer(4)') + ->assertExitCode(0) + ->execute(); + + // Assert + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'complex_calculation' => null, + 'sum_of_votes' => 0, + ]); + } + + public function testNonNumericChunkSizeIsReturnsErrorMessage() + { + $this->artisan('computed-attributes:validate', [ + '--chunkSize' => 'text', + ]) + ->expectsOutput('Option chunkSize needs to be an integer greater than zero') + ->assertExitCode(1) + ->execute(); + } + + public function testNegativeChunkSizeReturnsErrorMessage() + { + $this->artisan('computed-attributes:validate', [ + '--chunkSize' => '-10', + ]) + ->expectsOutput('Option chunkSize needs to be an integer greater than zero') + ->assertExitCode(1) + ->execute(); + } + + public function testZeroAsChunkSizeReturnsErrorMessage() + { + $this->artisan('computed-attributes:validate', [ + '--chunkSize' => '0', + ]) + ->expectsOutput('Option chunkSize needs to be greater than zero') + ->assertExitCode(1) + ->execute(); + } +} diff --git a/tests/Models/Post.php b/tests/Models/Post.php deleted file mode 100644 index fa4c960..0000000 --- a/tests/Models/Post.php +++ /dev/null @@ -1,35 +0,0 @@ -setComputedAttributeValue('complex_calculation'); - }); - parent::boot(); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..49669ac --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,33 @@ +loadMigrationsFrom(__DIR__.'/TestEnvironment/Migrations'); + } + + protected function getEnvironmentSetUp($app) + { + $app->setBasePath(__DIR__.'/TestEnvironment'); + } + + /** + * @param Application $app + * @return array + */ + protected function getPackageProviders($app) + { + return [ + LaravelComputedAttributesServiceProvider::class, + ]; + } +} diff --git a/tests/TestEnvironment/Migrations/0000_00_00_000000_add_posts_table.php b/tests/TestEnvironment/Migrations/0000_00_00_000000_add_posts_table.php new file mode 100644 index 0000000..b5af955 --- /dev/null +++ b/tests/TestEnvironment/Migrations/0000_00_00_000000_add_posts_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->string('title'); + $table->text('content'); + $table->integer('complex_calculation')->nullable(); + $table->integer('sum_of_votes'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('posts'); + } +} diff --git a/tests/TestEnvironment/Migrations/0000_00_00_000001_add_votes_table.php b/tests/TestEnvironment/Migrations/0000_00_00_000001_add_votes_table.php new file mode 100644 index 0000000..f7e5ffd --- /dev/null +++ b/tests/TestEnvironment/Migrations/0000_00_00_000001_add_votes_table.php @@ -0,0 +1,39 @@ +increments('id'); + $table->integer('rating'); + $table->integer('post_id'); + $table->foreign('post_id') + ->on('posts') + ->references('id') + ->onUpdate('CASCADE') + ->onDelete('RESTRICT'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('votes'); + } +} diff --git a/tests/TestEnvironment/Models/Post.php b/tests/TestEnvironment/Models/Post.php new file mode 100644 index 0000000..ad67d3d --- /dev/null +++ b/tests/TestEnvironment/Models/Post.php @@ -0,0 +1,113 @@ + 'int', + 'sum_of_votes' => 'int', + ]; + + /* + * Computed attributes. + */ + + /** + * @return int + */ + public function getComplexCalculationComputed(): int + { + return 1 + 2; + } + + /** + * @return int + */ + public function getSumOfVotesComputed(): int + { + return $this->votes->sum('rating'); + } + + /* + * Scopes. + */ + + /** + * This scope will be applied during the computed property generation with artisan computed-attributes:generate. + * + * @param Builder $builder + * @param array $attributes Attributes that will be generated. + * @return Builder + */ + public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder + { + if (in_array('sum_of_votes', $attributes)) { + return $builder->with('votes'); + } + + return $builder; + } + + /** + * This scope will be applied during the computed property validation with artisan computed-attributes:validate. + * + * @param Builder $builder + * @param array $attributes Attributes that will be validated. + * @return Builder + */ + public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder + { + if (in_array('sum_of_votes', $attributes)) { + return $builder->with('votes'); + } + + return $builder; + } + + /* + * Relations + */ + + /** + * @return HasMany|Vote + */ + public function votes(): HasMany + { + return $this->hasMany(Vote::class); + } + + /** + * Boot function from laravel. + */ + protected static function boot() + { + static::saving(function (Post $model) { + $model->setComputedAttributeValue('sum_of_votes'); + }); + parent::boot(); + } +} diff --git a/tests/TestEnvironment/Models/Vote.php b/tests/TestEnvironment/Models/Vote.php new file mode 100644 index 0000000..fd9988a --- /dev/null +++ b/tests/TestEnvironment/Models/Vote.php @@ -0,0 +1,44 @@ + 'int', + ]; + + /* + * Relations + */ + + /** + * @return BelongsTo + */ + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + /** + * Boot function from laravel. + */ + protected static function boot() + { + /* + Note: This listener is only commented out to test the commands on incorrect data. + static::saved(function (Vote $model) { + $model->post->setComputedAttributeValue('sum_of_votes'); + }); + */ + parent::boot(); + } +}