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();
+ }
+}