├── .gitignore ├── tests ├── Helpers │ └── Dummy │ │ ├── CanBeFired.php │ │ ├── DummyEloquentModel.php │ │ ├── DummyFailedCondition.php │ │ ├── DummyValidationDataTarget.php │ │ ├── DummySucceedCondition.php │ │ ├── DummyAction.php │ │ ├── DummyCondition.php │ │ ├── DummyTestHelper.php │ │ ├── DummyEloquentTarget.php │ │ └── DummyTarget.php ├── database │ ├── factories │ │ ├── DummyEloquentTargetFactory.php │ │ ├── ConditionFactory.php │ │ └── ConditionActionFactory.php │ └── migrations │ │ ├── 2019_02_20_123456_create_dummy_target_table.php │ │ ├── 2019_02_19_110944_create_condition_actions_table.php │ │ └── 2019_02_19_110943_create_conditions_table.php ├── EloquentConditionalActionsTestCase.php ├── helpers.php ├── Unit │ ├── Entities │ │ ├── Actions │ │ │ └── UpdateStateAttributeActionTest.php │ │ └── Conditions │ │ │ ├── HasChildrenConditionsTestCase.php │ │ │ ├── ValidationConditionTest.php │ │ │ ├── OneOfConditionTest.php │ │ │ └── AllOfConditionTest.php │ └── ConditionActionManagerTest.php ├── ConditionalActionsTestCase.php └── Feature │ ├── ConditionalActionManagerTest.php │ ├── Entities │ └── Eloquent │ │ ├── ConditionActionTest.php │ │ └── ConditionTest.php │ ├── Repositories │ ├── EloquentConditionRepositoryTest.php │ └── EloquentActionRepositoryTest.php │ ├── Traits │ └── EloquentTargetTest.php │ └── Http │ └── Conditions │ ├── ConditionsControllerTest.php │ └── ActionsControllerTest.php ├── src ├── Traits │ ├── UsesValidation.php │ ├── RunsConditionalActions.php │ └── EloquentTarget.php ├── ConditionalActionException.php ├── Contracts │ ├── TargetProviders │ │ └── ProvidesValidationData.php │ ├── StateContract.php │ ├── Repositories │ │ ├── ActionRepository.php │ │ └── ConditionRepository.php │ ├── TargetContract.php │ ├── ActionContract.php │ └── ConditionContract.php ├── Exceptions │ ├── ActionNotFoundException.php │ ├── ConditionNotFoundException.php │ └── BaseException.php ├── Http │ ├── Presenters │ │ ├── Presenter.php │ │ ├── ResponseWrapper.php │ │ ├── ActionPresenter.php │ │ ├── ConditionPresenter.php │ │ └── IterablePresenter.php │ └── Controllers │ │ ├── ActionsController.php │ │ └── ConditionsController.php ├── Entities │ ├── Actions │ │ ├── UpdateStateAttributeAction.php │ │ └── BaseAction.php │ ├── Conditions │ │ ├── TrueCondition.php │ │ ├── OneOfCondition.php │ │ ├── AllOfCondition.php │ │ ├── ValidationCondition.php │ │ └── BaseCondition.php │ ├── Eloquent │ │ ├── ValidatesModel.php │ │ ├── Action.php │ │ └── Condition.php │ └── State.php ├── RouteRegistrar.php ├── Console │ ├── stubs │ │ ├── condition_actions.stub │ │ └── conditions.stub │ └── ConditionalActionsTable.php ├── ConditionalActionsServiceProvider.php ├── ConditionalActionManager.php ├── ConditionalActions.php └── Repositories │ ├── EloquentActionRepository.php │ └── EloquentConditionRepository.php ├── .travis.yml ├── ci └── install-composer.sh ├── config └── conditional-actions.php ├── phpunit.xml ├── LICENSE ├── grumphp.yml ├── composer.json ├── docs └── openapi.yaml ├── .php_cs.dist.php └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | composer.lock 4 | .php_cs.cache 5 | 6 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/CanBeFired.php: -------------------------------------------------------------------------------- 1 | define(DummyEloquentModel::class, function (Generator $faker) { 7 | return [ 8 | 'name' => $faker->word, 9 | ]; 10 | }); 11 | -------------------------------------------------------------------------------- /src/Http/Presenters/Presenter.php: -------------------------------------------------------------------------------- 1 | responseRoot, $data); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Contracts/StateContract.php: -------------------------------------------------------------------------------- 1 | parameters as $key => $value) { 12 | $state->setAttribute($key, $value); 13 | } 14 | 15 | return $state; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/database/factories/ConditionFactory.php: -------------------------------------------------------------------------------- 1 | define(Condition::class, function (Generator $faker) { 8 | return [ 9 | 'name' => 'TrueCondition', 10 | ]; 11 | }); 12 | 13 | $factory->state(Condition::class, 'inactive', function (Generator $faker) { 14 | return [ 15 | 'ends_at' => Carbon::minValue()->toDateTimeString(), 16 | ]; 17 | }); 18 | -------------------------------------------------------------------------------- /tests/EloquentConditionalActionsTestCase.php: -------------------------------------------------------------------------------- 1 | withFactories(realpath(\dirname(__DIR__) . '/tests/database/factories')); 11 | $this->loadMigrationsFrom([ 12 | '--database' => 'testing', 13 | '--path' => realpath(\dirname(__DIR__) . '/tests/database/migrations'), 14 | ]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ci/install-composer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | EXPECTED_SIGNATURE="$(wget -q -O - https://composer.github.io/installer.sig)" 4 | php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" 5 | ACTUAL_SIGNATURE="$(php -r "echo hash_file('sha384', 'composer-setup.php');")" 6 | 7 | if [ "$EXPECTED_SIGNATURE" != "$ACTUAL_SIGNATURE" ] 8 | then 9 | >&2 echo 'ERROR: Invalid installer signature' 10 | rm composer-setup.php 11 | exit 1 12 | fi 13 | 14 | php composer-setup.php --quiet 15 | RESULT=$? 16 | rm composer-setup.php 17 | exit ${RESULT} 18 | -------------------------------------------------------------------------------- /src/Contracts/Repositories/ActionRepository.php: -------------------------------------------------------------------------------- 1 | define(Action::class, function (Generator $faker) { 9 | return [ 10 | 'condition_id' => factory(Condition::class), 11 | 'name' => 'UpdateStateAttributeAction', 12 | ]; 13 | }); 14 | 15 | $factory->state(Condition::class, 'inactive', function (Generator $faker) { 16 | return [ 17 | 'ends_at' => Carbon::minValue()->toDateTimeString(), 18 | ]; 19 | }); 20 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummyFailedCondition.php: -------------------------------------------------------------------------------- 1 | isFired = true; 21 | 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummyValidationDataTarget.php: -------------------------------------------------------------------------------- 1 | validationData = $validationData; 16 | } 17 | 18 | public function getValidationData(): array 19 | { 20 | return $this->validationData; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummySucceedCondition.php: -------------------------------------------------------------------------------- 1 | isFired = true; 21 | 22 | return true; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config/conditional-actions.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'AllOfCondition' => ConditionalActions\Entities\Conditions\AllOfCondition::class, 6 | 'OneOfCondition' => ConditionalActions\Entities\Conditions\OneOfCondition::class, 7 | 'TrueCondition' => ConditionalActions\Entities\Conditions\TrueCondition::class, 8 | 'ValidationCondition' => ConditionalActions\Entities\Conditions\ValidationCondition::class, 9 | ], 10 | 'actions' => [ 11 | 'UpdateStateAttributeAction' => ConditionalActions\Entities\Actions\UpdateStateAttributeAction::class, 12 | ], 13 | 'use_logger' => env('APP_DEBUG', false), 14 | ]; 15 | -------------------------------------------------------------------------------- /tests/helpers.php: -------------------------------------------------------------------------------- 1 | states($states)->create($attributes); 6 | } 7 | } 8 | 9 | if (!function_exists('createMany')) { 10 | function createMany($model, $keys = [], $attributesTable = [], $baseRow = [], string ...$states) { 11 | $collection = new \Illuminate\Database\Eloquent\Collection(); 12 | foreach ($attributesTable as $attributes) { 13 | $collection->push(create($model, array_merge($baseRow, array_combine($keys, $attributes)), ...$states)); 14 | } 15 | 16 | return $collection; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummyAction.php: -------------------------------------------------------------------------------- 1 | isFired = true; 22 | 23 | return $state; 24 | } 25 | 26 | public function isFired(): bool 27 | { 28 | return $this->isFired; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/database/migrations/2019_02_20_123456_create_dummy_target_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('name'); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('dummy_eloquent_models'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Entities/Eloquent/ValidatesModel.php: -------------------------------------------------------------------------------- 1 | attributesToArray(), 21 | static::validatingRules() 22 | ); 23 | 24 | if ($validator->fails()) { 25 | throw new ValidationException($validator); 26 | } 27 | } 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Contracts/TargetContract.php: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/Unit 13 | 14 | 15 | ./tests/Feature 16 | 17 | 18 | 19 | 20 | ./src 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Http/Presenters/ActionPresenter.php: -------------------------------------------------------------------------------- 1 | conditionalActions = $conditionalActions; 16 | } 17 | 18 | public function attributes(?ActionContract $action): array 19 | { 20 | return $action ? [ 21 | 'id' => $action->getId(), 22 | 'name' => $this->conditionalActions->getActionName(\get_class($action)), 23 | 'parameters' => $action->getParameters(), 24 | 'priority' => $action->getPriority(), 25 | 'starts_at' => $action->getStartsAt(), 26 | 'ends_at' => $action->getEndsAt(), 27 | ] : null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exceptions/BaseException.php: -------------------------------------------------------------------------------- 1 | getCode()]) 22 | ? $this->getCode() 23 | : Response::HTTP_INTERNAL_SERVER_ERROR; 24 | 25 | return response([ 26 | 'error' => [ 27 | 'message' => $this->getMessage(), 28 | ], 29 | ], $exceptionCode); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Entities/Conditions/OneOfCondition.php: -------------------------------------------------------------------------------- 1 | getChildrenConditions($this->id) as $condition) { 21 | $conditionResult = $condition->check($target, $state) !== $condition->isInverted(); 22 | 23 | if ($conditionResult === $this->expectedResult()) { 24 | $this->prependActions(...$condition->getActions()); 25 | 26 | return true; 27 | } 28 | } 29 | 30 | return false; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/RouteRegistrar.php: -------------------------------------------------------------------------------- 1 | router = $router; 24 | } 25 | 26 | /** 27 | * Register routes for transient tokens, clients, and personal access tokens. 28 | */ 29 | public function all() 30 | { 31 | $this->router->resource('conditions', 'ConditionsController') 32 | ->only('store', 'show', 'update', 'destroy') 33 | ->names('conditions'); 34 | 35 | $this->router->resource('actions', 'ActionsController') 36 | ->only('store', 'show', 'update', 'destroy') 37 | ->names('actions'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Http/Presenters/ConditionPresenter.php: -------------------------------------------------------------------------------- 1 | conditionalActions = $conditionalActions; 16 | } 17 | 18 | public function attributes(?ConditionContract $condition): array 19 | { 20 | return $condition ? [ 21 | 'id' => $condition->getId(), 22 | 'name' => $this->conditionalActions->getConditionName(\get_class($condition)), 23 | 'is_inverted' => $condition->isInverted(), 24 | 'parameters' => $condition->getParameters(), 25 | 'priority' => $condition->getPriority(), 26 | 'starts_at' => $condition->getStartsAt(), 27 | 'ends_at' => $condition->getEndsAt(), 28 | ] : null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Unit/Entities/Actions/UpdateStateAttributeActionTest.php: -------------------------------------------------------------------------------- 1 | 'first', 15 | 'two' => 'second', 16 | ]); 17 | /** @var UpdateStateAttributeAction $action */ 18 | $action = \app(UpdateStateAttributeAction::class); 19 | $action->setParameters([ 20 | 'one' => 'updated', 21 | 'four' => 'new attribute', 22 | ]); 23 | 24 | $newState = $action->apply($state); 25 | 26 | $this->assertEquals('updated', $newState->getAttribute('one')); 27 | $this->assertEquals('second', $newState->getAttribute('two')); 28 | $this->assertEquals('new attribute', $newState->getAttribute('four')); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Entities/Conditions/AllOfCondition.php: -------------------------------------------------------------------------------- 1 | getChildrenConditions($this->id) as $condition) { 23 | $conditionResult = $condition->check($target, $state) !== $condition->isInverted(); 24 | 25 | if ($conditionResult !== $this->expectedResult()) { 26 | return false; 27 | } 28 | $actions = \array_merge($actions, \collect($condition->getActions())->toArray()); 29 | } 30 | 31 | $this->prependActions(...$actions); 32 | 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Traits/RunsConditionalActions.php: -------------------------------------------------------------------------------- 1 | useLogger = \config('conditional-actions.use_logger'); 20 | 21 | return new State($attributes); 22 | } 23 | 24 | public function useLogger(): self 25 | { 26 | $this->useLogger = true; 27 | 28 | return $this; 29 | } 30 | 31 | public function runConditionalActions(): StateContract 32 | { 33 | /** @var ConditionalActionManager $manager */ 34 | $manager = \app(ConditionalActionManager::class); 35 | $manager->useLogger = $this->useLogger; 36 | 37 | return $manager->run($this, $this->getInitialState()); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/stubs/condition_actions.stub: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->unsignedInteger('condition_id'); 19 | $table->string('name'); 20 | $table->json('parameters')->nullable(); 21 | $table->integer('priority')->default(0); 22 | $table->timestamp('starts_at')->nullable(); 23 | $table->timestamp('ends_at')->nullable(); 24 | $table->timestamps(); 25 | $table->softDeletes(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function down() 35 | { 36 | Schema::dropIfExists('{{table}}'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 My.com B.V. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Entities/State.php: -------------------------------------------------------------------------------- 1 | setAttributes($attributes); 15 | } 16 | 17 | public function setAttributes(iterable $attributes): void 18 | { 19 | foreach ($attributes as $attribute => $value) { 20 | $this->attributes[$attribute] = $value; 21 | } 22 | } 23 | 24 | public function setAttribute(string $path, $value): void 25 | { 26 | Arr::set($this->attributes, $path, $value); 27 | } 28 | 29 | public function getAttribute(string $path, $default = null) 30 | { 31 | return Arr::get($this->attributes, $path, $default); 32 | } 33 | 34 | public function toArray(): array 35 | { 36 | return $this->attributes; 37 | } 38 | 39 | public function fromArray(array $array) 40 | { 41 | $this->setAttributes($array); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /grumphp.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | git_dir: . 3 | bin_dir: vendor/bin 4 | tasks: 5 | composer: 6 | file: ./composer.json 7 | no_check_publish: true 8 | 9 | git_blacklist: 10 | keywords: 11 | - "die(" 12 | - "var_dump(" 13 | - " dd(" 14 | - "dump(" 15 | - "exit;" 16 | - "exit(" 17 | 18 | phpcpd: 19 | directory: './src' 20 | fuzzy: true 21 | min_lines: 5 22 | min_tokens: 70 23 | triggered_by: ['php'] 24 | 25 | securitychecker: 26 | lockfile: composer.lock 27 | format: ~ 28 | end_point: ~ 29 | timeout: ~ 30 | run_always: false 31 | 32 | phpcsfixer2: 33 | allow_risky: true 34 | cache_file: '.php_cs.cache' 35 | config: '.php_cs.dist.php' 36 | rules: [] 37 | using_cache: true 38 | config_contains_finder: true 39 | verbose: true 40 | diff: true 41 | triggered_by: ['php'] 42 | 43 | phpmd: 44 | exclude: ['docs', 'tests', 'vendor'] 45 | ruleset: ['codesize', 'controversial', 'naming'] 46 | triggered_by: ['php'] 47 | 48 | phpversion: 49 | project: '7.2' 50 | -------------------------------------------------------------------------------- /tests/database/migrations/2019_02_19_110944_create_condition_actions_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->unsignedInteger('condition_id'); 19 | $table->string('name'); 20 | $table->json('parameters')->nullable(); 21 | $table->integer('priority')->default(0); 22 | $table->timestamp('starts_at')->nullable(); 23 | $table->timestamp('ends_at')->nullable(); 24 | $table->timestamps(); 25 | $table->softDeletes(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function down() 35 | { 36 | Schema::dropIfExists('condition_actions'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Http/Presenters/IterablePresenter.php: -------------------------------------------------------------------------------- 1 | presenter = $presenter; 15 | } 16 | 17 | public function __call($name, $arguments) 18 | { 19 | if (!\method_exists($this->presenter, $name)) { 20 | throw new RuntimeException( 21 | \sprintf('Method %s not found in presenter %s', $name, \get_class($this->presenter)) 22 | ); 23 | } 24 | 25 | if (!\count($arguments)) { 26 | throw new RuntimeException('No arguments'); 27 | } 28 | 29 | $items = \array_shift($arguments); 30 | 31 | if (!\is_iterable($items)) { 32 | throw new RuntimeException('Received items is not an iterable'); 33 | } 34 | 35 | $response = []; 36 | 37 | foreach ($items as $i => $item) { 38 | $response[$i] = $this->presenter->{$name}($item); 39 | } 40 | 41 | return $response; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-com/laravel-conditional-actions", 3 | "authors": [ 4 | { 5 | "name": "Aleksandr Paramonov", 6 | "email": "a.paramonov@corp.my.com" 7 | } 8 | ], 9 | "license": "MIT", 10 | "autoload": { 11 | "psr-4": { 12 | "ConditionalActions\\": "./src" 13 | } 14 | }, 15 | "autoload-dev": { 16 | "psr-4": { 17 | "Tests\\": "./tests" 18 | }, 19 | "files": ["./tests/helpers.php"] 20 | }, 21 | "require": { 22 | "illuminate/support": "^6.0", 23 | "illuminate/database": "^6.0", 24 | "illuminate/config": "^6.0", 25 | "illuminate/http": "^6.0", 26 | "ext-json": "*" 27 | }, 28 | "extra": { 29 | "laravel": { 30 | "providers": "ConditionalActions\\ConditionalActionsServiceProvider" 31 | } 32 | }, 33 | "require-dev": { 34 | "orchestra/testbench": "^4.0", 35 | "mockery/mockery": "^1.2", 36 | "phpro/grumphp": "^0.15.0", 37 | "phpmd/phpmd": "^2.6", 38 | "friendsofphp/php-cs-fixer": "^2.14", 39 | "sebastian/phpcpd": "^4.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Entities/Conditions/ValidationCondition.php: -------------------------------------------------------------------------------- 1 | getValidationData(), $this->parameters ?? []); 33 | 34 | return !$validator->fails(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Console/stubs/conditions.stub: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->nullableMorphs('target'); 19 | $table->string('name'); 20 | $table->json('parameters')->nullable(); 21 | $table->boolean('is_inverted')->default(false); 22 | $table->integer('priority')->default(0); 23 | $table->unsignedInteger('parent_id')->nullable(); 24 | $table->timestamp('starts_at')->nullable(); 25 | $table->timestamp('ends_at')->nullable(); 26 | $table->timestamps(); 27 | $table->softDeletes(); 28 | }); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | * 34 | * @return void 35 | */ 36 | public function down() 37 | { 38 | Schema::dropIfExists('{{table}}'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/ConditionalActionsTestCase.php: -------------------------------------------------------------------------------- 1 | true, 23 | 'conditional-actions.conditions.DummySucceedCondition' => DummySucceedCondition::class, 24 | 'conditional-actions.conditions.DummyFailedCondition' => DummyFailedCondition::class, 25 | 'conditional-actions.actions.DummyAction' => DummyAction::class, 26 | ]); 27 | 28 | Carbon::serializeUsing(function (Carbon $carbon) { 29 | return $carbon->toIso8601String(); 30 | }); 31 | } 32 | 33 | protected function getPackageProviders($app) 34 | { 35 | return [ConditionalActionsServiceProvider::class]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/database/migrations/2019_02_19_110943_create_conditions_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->nullableMorphs('target'); 19 | $table->string('name'); 20 | $table->json('parameters')->nullable(); 21 | $table->boolean('is_inverted')->default(false); 22 | $table->integer('priority')->default(0); 23 | $table->unsignedInteger('parent_id')->nullable(); 24 | $table->timestamp('starts_at')->nullable(); 25 | $table->timestamp('ends_at')->nullable(); 26 | $table->timestamps(); 27 | $table->softDeletes(); 28 | }); 29 | } 30 | 31 | /** 32 | * Reverse the migrations. 33 | * 34 | * @return void 35 | */ 36 | public function down() 37 | { 38 | Schema::dropIfExists('conditions'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummyCondition.php: -------------------------------------------------------------------------------- 1 | id = $id; 19 | $this->parentId = $parentId; 20 | } 21 | 22 | public static function withActions(int $id, ?int $parentId, ActionContract ...$actions): self 23 | { 24 | return \tap(new static($id, $parentId), function (self $condition) use ($actions) { 25 | $condition->setActions($actions); 26 | }); 27 | } 28 | 29 | /** 30 | * @return int 31 | */ 32 | public function getId(): int 33 | { 34 | return $this->id; 35 | } 36 | 37 | /** 38 | * @return int 39 | */ 40 | public function getParentId(): ?int 41 | { 42 | return $this->parentId; 43 | } 44 | 45 | /** 46 | * @return bool 47 | */ 48 | public function isFired(): bool 49 | { 50 | return $this->isFired; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ConditionalActionsServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 17 | __DIR__ . '/../config/conditional-actions.php', 18 | 'conditional-actions' 19 | ); 20 | 21 | $this->publishes([ 22 | __DIR__ . '/../config/conditional-actions.php' => \config_path('conditional-actions.php'), 23 | ]); 24 | 25 | $this->commands([ 26 | ConditionalActionsTable::class, 27 | ]); 28 | } 29 | 30 | public function register() 31 | { 32 | $this->app->singleton(ConditionalActions::class); 33 | $this->app->bind(ActionRepository::class, EloquentActionRepository::class); 34 | $this->app->bind(ConditionRepository::class, EloquentConditionRepository::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummyTestHelper.php: -------------------------------------------------------------------------------- 1 | id, $parentId, ...$actions); 31 | } 32 | 33 | protected function failedCondition(?int $parentId = null, ActionContract ...$actions): DummyCondition 34 | { 35 | return DummyFailedCondition::withActions(++$this->id, $parentId, ...$actions); 36 | } 37 | 38 | protected function assertFired(CanBeFired ...$items): void 39 | { 40 | foreach ($items as $item) { 41 | Assert::assertTrue($item->isFired()); 42 | } 43 | } 44 | 45 | protected function assertNotFired(CanBeFired ...$items): void 46 | { 47 | foreach ($items as $item) { 48 | Assert::assertFalse($item->isFired()); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Unit/Entities/Conditions/HasChildrenConditionsTestCase.php: -------------------------------------------------------------------------------- 1 | target = new DummyTarget(); 26 | $this->action = new DummyAction(); 27 | 28 | $this->testCondition = $condition; 29 | $this->testCondition->setId(++$this->id); 30 | $this->testCondition->setActions([$this->action]); 31 | 32 | $this->target->addConditions($this->testCondition); 33 | } 34 | 35 | protected function succeedChildrenCondition(ActionContract ...$actions): DummyCondition 36 | { 37 | return $this->succeedCondition($this->testCondition->getId(), ...$actions); 38 | } 39 | 40 | protected function failedChildrenCondition(ActionContract ...$actions): DummyCondition 41 | { 42 | return $this->failedCondition($this->testCondition->getId(), ...$actions); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Traits/EloquentTarget.php: -------------------------------------------------------------------------------- 1 | morphMany(Condition::class, 'target'); 21 | } 22 | 23 | /** 24 | * Gets target conditions. 25 | * 26 | * @return iterable|ConditionContract[] 27 | */ 28 | public function getRootConditions(): iterable 29 | { 30 | return $this->getChildrenConditions(null); 31 | } 32 | 33 | /** 34 | * Gets children target conditions. 35 | * 36 | * @param int $parentId 37 | * 38 | * @return iterable|ConditionContract[] 39 | */ 40 | public function getChildrenConditions(?int $parentId): iterable 41 | { 42 | return $this->conditions 43 | ->filter(function (Condition $condition) use ($parentId) { 44 | return $condition->parent_id === $parentId && $condition->isActive(); 45 | }) 46 | ->sortBy('priority') 47 | ->map(function (Condition $condition) { 48 | return $condition->toCondition(); 49 | }) 50 | ->values(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Feature/ConditionalActionManagerTest.php: -------------------------------------------------------------------------------- 1 | 'AllOfCondition']); 19 | /** @var Condition $child */ 20 | $child = \create(Condition::class, ['name' => 'TrueCondition', 'parent_id' => $allOf->id]); 21 | 22 | \create(Action::class, [ 23 | 'name' => 'UpdateStateAttributeAction', 24 | 'condition_id' => $allOf->id, 25 | 'parameters' => ['value' => 10], 26 | ]); 27 | 28 | \create(Action::class, [ 29 | 'name' => 'UpdateStateAttributeAction', 30 | 'condition_id' => $allOf->id, 31 | 'parameters' => ['value' => 20], 32 | ]); 33 | 34 | $model->conditions()->saveMany([$allOf, $child]); 35 | 36 | $target = new DummyEloquentTarget($model); 37 | 38 | $state = $target->runConditionalActions(); 39 | 40 | $this->assertEquals(20, $state->getAttribute('value')); 41 | $this->assertEquals(['value' => 20], $target->state); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummyEloquentTarget.php: -------------------------------------------------------------------------------- 1 | model = $model; 22 | } 23 | 24 | /** 25 | * Gets state from target. 26 | * 27 | * @return StateContract 28 | */ 29 | public function getInitialState(): StateContract 30 | { 31 | return $this->newState(['value' => 1]); 32 | } 33 | 34 | /** 35 | * Sets the state to the target. 36 | * 37 | * @param StateContract $state 38 | */ 39 | public function applyState(StateContract $state): void 40 | { 41 | $this->state = $state->toArray(); 42 | } 43 | 44 | /** 45 | * Gets root target conditions. 46 | * 47 | * @return iterable|ConditionContract[] 48 | */ 49 | public function getRootConditions(): iterable 50 | { 51 | return $this->model->getRootConditions(); 52 | } 53 | 54 | /** 55 | * Gets children target conditions. 56 | * 57 | * @param int $parentId 58 | * 59 | * @return iterable|ConditionContract[] 60 | */ 61 | public function getChildrenConditions(int $parentId): iterable 62 | { 63 | return $this->model->getChildrenConditions($parentId); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ConditionalActionManager.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 19 | } 20 | 21 | public function run(TargetContract $target, StateContract $state): StateContract 22 | { 23 | $this->log(\sprintf('Initial state: %s', \json_encode($state->toArray()))); 24 | 25 | foreach ($target->getRootConditions() as $condition) { 26 | $conditionName = \sprintf( 27 | '%s "%s"', 28 | $condition->isInverted() ? 'Inverted condition' : 'Condition', 29 | \class_basename($condition) 30 | ); 31 | 32 | if ($condition->check($target, $state) !== $condition->isInverted()) { 33 | $this->log("[OK]\t" . $conditionName); 34 | 35 | foreach ($condition->getActions() as $action) { 36 | $state = $action->apply($state); 37 | 38 | $this->log(\sprintf(' Apply action "%s"', \class_basename($action))); 39 | $this->log(\sprintf(' => New state: %s', \json_encode($state->toArray()))); 40 | } 41 | } else { 42 | $this->log("[SKIP]\t" . $conditionName); 43 | } 44 | } 45 | 46 | $target->applyState($state); 47 | $this->log(\sprintf('Finish state: %s', \json_encode($state->toArray()))); 48 | 49 | return $state; 50 | } 51 | 52 | protected function log(string $message) 53 | { 54 | if (!$this->useLogger) { 55 | return; 56 | } 57 | 58 | $this->logger->debug($message); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ConditionalActions.php: -------------------------------------------------------------------------------- 1 | conditionNames)) { 19 | return; 20 | } 21 | 22 | $this->conditionNames = \array_flip(\config('conditional-actions.conditions', [])); 23 | $this->actionNames = \array_flip(\config('conditional-actions.actions', [])); 24 | } 25 | 26 | public function getActionNames(): array 27 | { 28 | $this->loadConfig(); 29 | 30 | return $this->actionNames; 31 | } 32 | 33 | public function getActionName(string $className): ?string 34 | { 35 | $this->loadConfig(); 36 | 37 | return Arr::get($this->actionNames, $className); 38 | } 39 | 40 | public function getConditionNames(): array 41 | { 42 | $this->loadConfig(); 43 | 44 | return $this->conditionNames; 45 | } 46 | 47 | public function getConditionName(string $className): ?string 48 | { 49 | $this->loadConfig(); 50 | 51 | return Arr::get($this->conditionNames, $className); 52 | } 53 | 54 | public static function routes($callback = null, array $options = []) 55 | { 56 | $callback = $callback ?: function ($router) { 57 | $router->all(); 58 | }; 59 | $defaultOptions = [ 60 | 'prefix' => 'api/v1/conditional-actions', 61 | 'namespace' => 'ConditionalActions\Http\Controllers', 62 | ]; 63 | 64 | $options = \array_merge($defaultOptions, $options); 65 | Route::group($options, function ($router) use ($callback) { 66 | $callback(new RouteRegistrar($router)); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Unit/Entities/Conditions/ValidationConditionTest.php: -------------------------------------------------------------------------------- 1 | condition = new ValidationCondition(); 20 | } 21 | 22 | /** 23 | * @param array $validationData 24 | * @param array $conditionParams 25 | * @param bool $result 26 | * 27 | * @dataProvider provider_test_validation 28 | * @throws ConditionalActionException 29 | */ 30 | public function test_validation(array $validationData, array $conditionParams, bool $result) 31 | { 32 | $this->condition->setParameters($conditionParams); 33 | $target = new DummyValidationDataTarget($validationData); 34 | 35 | $this->assertEquals($result, $this->condition->check($target, $target->getInitialState())); 36 | } 37 | 38 | public function provider_test_validation(): array 39 | { 40 | return [ 41 | 'succeeded' => [ 42 | ['foo' => ['bar' => 10]], 43 | ['foo.bar' => 'required|int|max:9'], 44 | false, 45 | ], 46 | 'failed' => [ 47 | ['foo' => ['bar' => 10]], 48 | ['foo.bar' => 'required|int|max:11'], 49 | true, 50 | ], 51 | ]; 52 | } 53 | 54 | /** 55 | * @throws ConditionalActionException 56 | */ 57 | public function test_exception_when_provides_validation_data_contract_not_implemented() 58 | { 59 | $target = new DummyTarget(); 60 | 61 | $this->expectException(ConditionalActionException::class); 62 | $this->expectExceptionCode(400); 63 | 64 | $this->condition->check($target, $target->getInitialState()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Repositories/EloquentActionRepository.php: -------------------------------------------------------------------------------- 1 | model = $model; 21 | } 22 | 23 | public function find(int $id): ActionContract 24 | { 25 | return \optional($this->model->newQuery()->find($id))->toAction(); 26 | } 27 | 28 | public function store(array $attributes): ActionContract 29 | { 30 | return \optional($this->model->newQuery()->create($attributes))->toAction(); 31 | } 32 | 33 | /** 34 | * @param int $id 35 | * @param array $attributes 36 | * 37 | * @throws ActionNotFoundException 38 | * @throws \Throwable 39 | * 40 | * @return ActionContract 41 | */ 42 | public function update(int $id, array $attributes): ActionContract 43 | { 44 | /** @var Action $condition */ 45 | $condition = $this->model->newQuery()->find($id); 46 | 47 | if (!$condition) { 48 | throw new ActionNotFoundException(\sprintf('Action %s not found', $id)); 49 | } 50 | 51 | $condition->update($attributes); 52 | 53 | return $condition->toAction(); 54 | } 55 | 56 | /** 57 | * @param int $id 58 | * 59 | * @throws ActionNotFoundException 60 | * @throws \Throwable 61 | * 62 | * @return ActionContract 63 | */ 64 | public function destroy(int $id): ActionContract 65 | { 66 | /** @var Action $condition */ 67 | $condition = $this->model->newQuery()->find($id); 68 | 69 | if (!$condition) { 70 | throw new ActionNotFoundException(\sprintf('Action %s not found', $id)); 71 | } 72 | 73 | $condition->delete(); 74 | 75 | return $condition->toAction(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Repositories/EloquentConditionRepository.php: -------------------------------------------------------------------------------- 1 | model = $model; 21 | } 22 | 23 | public function find(int $id): ConditionContract 24 | { 25 | return \optional($this->model->newQuery()->find($id))->toCondition(); 26 | } 27 | 28 | public function store(array $attributes): ConditionContract 29 | { 30 | return \optional($this->model->newQuery()->create($attributes))->toCondition(); 31 | } 32 | 33 | /** 34 | * @param int $id 35 | * @param array $attributes 36 | * 37 | * @throws ConditionNotFoundException 38 | * @throws \Throwable 39 | * 40 | * @return ConditionContract 41 | */ 42 | public function update(int $id, array $attributes): ConditionContract 43 | { 44 | /** @var Condition $condition */ 45 | $condition = $this->model->newQuery()->find($id); 46 | 47 | if (!$condition) { 48 | throw new ConditionNotFoundException(\sprintf('Condition %s not found', $id)); 49 | } 50 | 51 | $condition->update($attributes); 52 | 53 | return $condition->toCondition(); 54 | } 55 | 56 | /** 57 | * @param int $id 58 | * 59 | * @throws ConditionNotFoundException 60 | * @throws \Throwable 61 | * 62 | * @return ConditionContract 63 | */ 64 | public function destroy(int $id): ConditionContract 65 | { 66 | /** @var Condition $condition */ 67 | $condition = $this->model->newQuery()->find($id); 68 | 69 | if (!$condition) { 70 | throw new ConditionNotFoundException(\sprintf('Condition %s not found', $id)); 71 | } 72 | 73 | $condition->delete(); 74 | 75 | return $condition->toCondition(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/Helpers/Dummy/DummyTarget.php: -------------------------------------------------------------------------------- 1 | conditions = \collect(); 26 | } 27 | 28 | /** 29 | * Gets state from target. 30 | * 31 | * @return StateContract 32 | */ 33 | public function getInitialState(): StateContract 34 | { 35 | return $this->newState([]); 36 | } 37 | 38 | /** 39 | * Sets the state to the target. 40 | * 41 | * @param StateContract $state 42 | */ 43 | public function applyState(StateContract $state): void 44 | { 45 | $this->isFired = true; 46 | $this->state = $state; 47 | } 48 | 49 | /** 50 | * Gets root target conditions. 51 | * 52 | * @return iterable|ConditionContract[] 53 | */ 54 | public function getRootConditions(): iterable 55 | { 56 | return $this->getChildrenConditions(null); 57 | } 58 | 59 | /** 60 | * Gets children target conditions. 61 | * 62 | * @param int|null $parentId 63 | * 64 | * @return iterable|ConditionContract[] 65 | */ 66 | public function getChildrenConditions(?int $parentId): iterable 67 | { 68 | return $this->conditions->filter(function (ConditionContract $condition) use ($parentId) { 69 | return $condition->getParentId() === $parentId; 70 | }); 71 | } 72 | 73 | public function addConditions(ConditionContract ...$conditions): self 74 | { 75 | foreach ($conditions as $condition) { 76 | $this->conditions->push($condition); 77 | } 78 | 79 | return $this; 80 | } 81 | 82 | public function isFired(): bool 83 | { 84 | return $this->isFired; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Contracts/ActionContract.php: -------------------------------------------------------------------------------- 1 | manager = \app(ConditionalActionManager::class); 22 | $this->target = new DummyTarget(); 23 | } 24 | 25 | public function test_run_condition_actions_when_condition_succeed() 26 | { 27 | $actions = $this->makeActions(2); 28 | $condition = $this->succeedCondition(null, ...$actions); 29 | $this->target->addConditions($condition); 30 | 31 | $this->target->runConditionalActions(); 32 | 33 | $this->assertFired($condition); 34 | $this->assertFired(...$actions); 35 | $this->assertFired($this->target); 36 | } 37 | 38 | public function test_not_run_condition_actions_when_condition_failed() 39 | { 40 | $actions = $this->makeActions(2); 41 | $condition = $this->failedCondition(null, ...$actions); 42 | $this->target->addConditions($condition); 43 | 44 | $this->target->runConditionalActions(); 45 | 46 | $this->assertFired($condition); 47 | $this->assertNotFired(...$actions); 48 | } 49 | 50 | public function test_run_condition_check_even_if_previous_condition_is_failed() 51 | { 52 | $actions = $this->makeActions(2); 53 | $conditions = [ 54 | $this->failedCondition(null, $actions[0]), 55 | $this->succeedCondition(null, $actions[1]), 56 | ]; 57 | $this->target->addConditions(...$conditions); 58 | 59 | $this->target->runConditionalActions(); 60 | 61 | $this->assertFired(...$conditions); 62 | $this->assertNotFired($actions[0]); 63 | $this->assertFired($actions[1]); 64 | } 65 | 66 | public function test_inverted_failed_condition_is_succeed_condition() 67 | { 68 | $actions = $this->makeActions(2); 69 | $condition = $this->failedCondition(null, ...$actions); 70 | $condition->setIsInverted(true); 71 | $this->target->addConditions($condition); 72 | 73 | $this->target->runConditionalActions(); 74 | 75 | $this->assertFired($condition); 76 | $this->assertFired(...$actions); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Entities/Eloquent/Action.php: -------------------------------------------------------------------------------- 1 | 'datetime', 42 | 'ends_at' => 'datetime', 43 | 'parameters' => 'array', 44 | 'priority' => 'int', 45 | 'condition_id' => 'int', 46 | ]; 47 | 48 | public static function validatingRules(): array 49 | { 50 | return ['name' => 'required']; 51 | } 52 | 53 | public function isActive(): bool 54 | { 55 | return Carbon::now()->between( 56 | $this->starts_at ?? Carbon::minValue(), 57 | $this->ends_at ?? Carbon::maxValue() 58 | ); 59 | } 60 | 61 | /** 62 | * @throws \Throwable 63 | * 64 | * @return ActionContract 65 | */ 66 | public function toAction(): ActionContract 67 | { 68 | $className = \config("conditional-actions.actions.{$this->name}"); 69 | 70 | \throw_unless( 71 | $className, 72 | ActionNotFoundException::class, 73 | \sprintf('Action %s not found', $this->name), 74 | Response::HTTP_NOT_FOUND 75 | ); 76 | 77 | /** @var ActionContract $action */ 78 | $action = \app($className); 79 | $action 80 | ->setId($this->id) 81 | ->setParameters($this->parameters ?? []) 82 | ->setPriority($this->priority ?? 0) 83 | ->setStartsAt($this->starts_at) 84 | ->setEndsAt($this->ends_at); 85 | 86 | return $action->setParameters($this->parameters); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Http/Controllers/ActionsController.php: -------------------------------------------------------------------------------- 1 | actionRepository = $actionRepository; 28 | $this->actionPresenter = $actionPresenter; 29 | } 30 | 31 | public function show(int $actionId) 32 | { 33 | $action = $this->actionRepository->find($actionId); 34 | 35 | return $this->jsonResponse($this->actionPresenter->attributes($action)); 36 | } 37 | 38 | public function store(Request $request, ConditionalActions $conditionalActions) 39 | { 40 | $data = $this->validate($request, [ 41 | 'name' => ['required', new In($conditionalActions->getActionNames())], 42 | 'condition_id' => 'required|numeric', 43 | 'parameters' => 'array', 44 | 'priority' => 'integer', 45 | 'starts_at' => 'date', 46 | 'ends_at' => 'date', 47 | ]); 48 | 49 | $action = $this->actionRepository->store($data); 50 | 51 | return $this->jsonResponse($this->actionPresenter->attributes($action)); 52 | } 53 | 54 | public function update(Request $request, ConditionalActions $conditionalActions, int $actionId) 55 | { 56 | $data = $this->validate($request, [ 57 | 'name' => ['required', new In($conditionalActions->getActionNames())], 58 | 'condition_id' => 'required|numeric', 59 | 'parameters' => 'array', 60 | 'priority' => 'integer', 61 | 'starts_at' => 'date', 62 | 'ends_at' => 'date', 63 | ]); 64 | 65 | $action = $this->actionRepository->update($actionId, $data); 66 | 67 | return $this->jsonResponse($this->actionPresenter->attributes($action)); 68 | } 69 | 70 | public function destroy(int $actionId) 71 | { 72 | $condition = $this->actionRepository->destroy($actionId); 73 | 74 | return $this->jsonResponse($this->actionPresenter->attributes($condition)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Http/Controllers/ConditionsController.php: -------------------------------------------------------------------------------- 1 | conditionsRepository = $conditionRepository; 28 | $this->conditionPresenter = $conditionPresenter; 29 | } 30 | 31 | public function show(int $conditionId) 32 | { 33 | $condition = $this->conditionsRepository->find($conditionId); 34 | 35 | return $this->jsonResponse($this->conditionPresenter->attributes($condition)); 36 | } 37 | 38 | public function store(Request $request, ConditionalActions $conditionalActions) 39 | { 40 | $data = $this->validate($request, [ 41 | 'name' => ['required', new In($conditionalActions->getConditionNames())], 42 | 'is_inverted' => 'boolean', 43 | 'parameters' => 'array', 44 | 'priority' => 'integer', 45 | 'starts_at' => 'date', 46 | 'ends_at' => 'date', 47 | ]); 48 | 49 | $condition = $this->conditionsRepository->store($data); 50 | 51 | return $this->jsonResponse($this->conditionPresenter->attributes($condition)); 52 | } 53 | 54 | public function update(Request $request, ConditionalActions $conditionalActions, int $conditionId) 55 | { 56 | $data = $this->validate($request, [ 57 | 'name' => ['required', new In($conditionalActions->getConditionNames())], 58 | 'is_inverted' => 'boolean', 59 | 'parameters' => 'array', 60 | 'priority' => 'integer', 61 | 'starts_at' => 'date', 62 | 'ends_at' => 'date', 63 | ]); 64 | 65 | $condition = $this->conditionsRepository->update($conditionId, $data); 66 | 67 | return $this->jsonResponse($this->conditionPresenter->attributes($condition)); 68 | } 69 | 70 | public function destroy(int $conditionId) 71 | { 72 | $condition = $this->conditionsRepository->destroy($conditionId); 73 | 74 | return $this->jsonResponse($this->conditionPresenter->attributes($condition)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Entities/Actions/BaseAction.php: -------------------------------------------------------------------------------- 1 | parameters = $parameters ?? []; 38 | 39 | return $this; 40 | } 41 | 42 | /** 43 | * Gets action parameters. 44 | * 45 | * @return iterable 46 | */ 47 | public function getParameters(): iterable 48 | { 49 | return $this->parameters; 50 | } 51 | 52 | /** 53 | * Sets the action identifier. 54 | * 55 | * @param int|null $id 56 | * 57 | * @return BaseAction 58 | */ 59 | public function setId(?int $id): self 60 | { 61 | $this->id = $id; 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Gets action identifier. 68 | * 69 | * @return int|null 70 | */ 71 | public function getId(): ?int 72 | { 73 | return $this->id; 74 | } 75 | 76 | /** 77 | * Sets the action priority. 78 | * 79 | * @param int $priority 80 | * 81 | * @return BaseAction 82 | */ 83 | public function setPriority(int $priority): self 84 | { 85 | $this->priority = $priority; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Gets action priority. 92 | * 93 | * @return int 94 | */ 95 | public function getPriority(): int 96 | { 97 | return $this->priority; 98 | } 99 | 100 | /** 101 | * Sets the start time. 102 | * 103 | * @param Carbon|null $startsAt 104 | * 105 | * @return BaseAction 106 | */ 107 | public function setStartsAt(?Carbon $startsAt): self 108 | { 109 | $this->startsAt = $startsAt; 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * Gets start time. 116 | * 117 | * @return Carbon|null 118 | */ 119 | public function getStartsAt(): ?Carbon 120 | { 121 | return $this->startsAt; 122 | } 123 | 124 | /** 125 | * Sets the finish time. 126 | * 127 | * @param Carbon|null $endsAt 128 | * 129 | * @return BaseAction 130 | */ 131 | public function setEndsAt(?Carbon $endsAt): self 132 | { 133 | $this->endsAt = $endsAt; 134 | 135 | return $this; 136 | } 137 | 138 | /** 139 | * Gets finish time. 140 | * 141 | * @return Carbon|null 142 | */ 143 | public function getEndsAt(): ?Carbon 144 | { 145 | return $this->endsAt; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Console/ConditionalActionsTable.php: -------------------------------------------------------------------------------- 1 | files = $files; 42 | $this->composer = $composer; 43 | } 44 | 45 | /** 46 | * @throws FileNotFoundException 47 | */ 48 | public function handle() 49 | { 50 | $path = $this->laravel->basePath($this->option('migrations-path')); 51 | $tables = ['conditions', 'condition_actions']; 52 | 53 | foreach ($tables as $i => $table) { 54 | // Sleep needs to correct migrations order 55 | if ($i > 0) { 56 | \sleep(1); 57 | } 58 | $filename = $this->replaceMigration( 59 | $this->createBaseMigration($table, $path), 60 | $table, 61 | Str::studly($table) 62 | ); 63 | 64 | $fileName = \trim(Str::after($filename, $path), '/'); 65 | $this->line(\sprintf('Migration created: %s', $fileName)); 66 | } 67 | 68 | $this->composer->dumpAutoloads(); 69 | } 70 | 71 | /** 72 | * Create a base migration file for the table. 73 | * 74 | * @param string $table 75 | * @param string $path 76 | * 77 | * @return string 78 | */ 79 | protected function createBaseMigration(string $table, string $path) 80 | { 81 | return $this->laravel['migration.creator']->create( 82 | 'create_' . $table . '_table', 83 | $path 84 | ); 85 | } 86 | 87 | /** 88 | * Replace the generated migration with the table stub. 89 | * 90 | * @param string $path 91 | * @param string $table 92 | * @param string $tableClassName 93 | * 94 | * @throws FileNotFoundException 95 | * 96 | * @return string 97 | */ 98 | protected function replaceMigration($path, $table, $tableClassName) 99 | { 100 | $stub = \str_replace( 101 | ['{{table}}', '{{tableClassName}}'], 102 | [$table, $tableClassName], 103 | $this->files->get(__DIR__ . "/stubs/{$table}.stub") 104 | ); 105 | 106 | $this->files->put($path, $stub); 107 | 108 | return $path; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/Feature/Entities/Eloquent/ConditionActionTest.php: -------------------------------------------------------------------------------- 1 | now)); 22 | } 23 | 24 | /** 25 | * @dataProvider provider_is_active 26 | * 27 | * @param bool $isActive 28 | * @param Carbon|null $startsAt 29 | * @param Carbon|null $endsAt 30 | */ 31 | public function test_is_active(bool $isActive, $startsAt, $endsAt) 32 | { 33 | /** @var Action $action */ 34 | $action = \create(Action::class, [ 35 | 'starts_at' => $startsAt, 36 | 'ends_at' => $endsAt, 37 | ]); 38 | 39 | $this->assertEquals($isActive, $action->isActive()); 40 | } 41 | 42 | public function provider_is_active(): array 43 | { 44 | Carbon::setTestNow(Carbon::parse($this->now)); 45 | 46 | return [ 47 | 'starts_at at now and ends_at in future' => [true, Carbon::now(), Carbon::tomorrow()], 48 | 'starts_at in past and ends_at at now' => [true, Carbon::yesterday(), Carbon::now()], 49 | 'starts_at in past and ends_at in future' => [true, Carbon::yesterday(), Carbon::tomorrow()], 50 | 'starts_at is null and ends_at at now' => [true, null, Carbon::now()], 51 | 'starts_at at now and ends_at is null' => [true, Carbon::now(), null], 52 | 'starts_at is null and ends_at is null' => [true, null, null], 53 | 'starts_at in past and ends_at in past' => [false, Carbon::yesterday()->subDay(), Carbon::yesterday()], 54 | 'starts_at is null and ends_at in past' => [false, null, Carbon::yesterday()], 55 | 'starts_at in future and ends_at in future' => [false, Carbon::tomorrow(), Carbon::tomorrow()->addDay()], 56 | 'starts_at in future and ends_at is null' => [false, Carbon::tomorrow(), null], 57 | ]; 58 | } 59 | 60 | public function test_to_action_exception_when_action_not_exists() 61 | { 62 | /** @var Action $action */ 63 | $action = \create(Action::class, ['name' => 'NotExists']); 64 | 65 | $this->expectException(ActionNotFoundException::class); 66 | $this->expectExceptionCode(Response::HTTP_NOT_FOUND); 67 | $this->expectExceptionMessage('Action NotExists not found'); 68 | 69 | $action->toAction(); 70 | } 71 | 72 | public function test_to_action_make_correct_action() 73 | { 74 | /** @var Action $action */ 75 | $action = \create(Action::class, [ 76 | 'name' => 'UpdateStateAttributeAction', 77 | 'parameters' => ['one' => 'first'], 78 | ]); 79 | 80 | $actualAction = $action->toAction(); 81 | 82 | $this->assertInstanceOf(UpdateStateAttributeAction::class, $actualAction); 83 | $this->assertEquals($action->parameters, $actualAction->getParameters()); 84 | } 85 | 86 | public function test_validate_name() 87 | { 88 | $this->expectException(ValidationException::class); 89 | \create(Action::class, ['name' => '']); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Contracts/ConditionContract.php: -------------------------------------------------------------------------------- 1 | 'datetime', 49 | 'ends_at' => 'datetime', 50 | 'parameters' => 'array', 51 | 'is_inverted' => 'boolean', 52 | 'priority' => 'int', 53 | 'parent_id' => 'int', 54 | 'target_id' => 'int', 55 | ]; 56 | 57 | public static function validatingRules(): array 58 | { 59 | return ['name' => 'required']; 60 | } 61 | 62 | public function actions(): HasMany 63 | { 64 | return $this->hasMany(Action::class); 65 | } 66 | 67 | public function childrenConditions(): HasMany 68 | { 69 | return $this->hasMany(self::class, 'parent_id', 'id'); 70 | } 71 | 72 | public function isActive(): bool 73 | { 74 | return Carbon::now()->between( 75 | $this->starts_at ?? Carbon::minValue(), 76 | $this->ends_at ?? Carbon::maxValue() 77 | ); 78 | } 79 | 80 | /** 81 | * @throws \Throwable 82 | * 83 | * @return ConditionContract 84 | */ 85 | public function toCondition(): ConditionContract 86 | { 87 | $className = \config("conditional-actions.conditions.{$this->name}"); 88 | 89 | \throw_unless( 90 | $className, 91 | ConditionNotFoundException::class, 92 | \sprintf('Condition %s not found', $this->name), 93 | Response::HTTP_NOT_FOUND 94 | ); 95 | 96 | /** @var ConditionContract $condition */ 97 | $condition = \app($className); 98 | 99 | return $condition->setId($this->id) 100 | ->setActions($this->getActiveActions()->map(function (Action $action) { 101 | return $action->toAction(); 102 | })) 103 | ->setIsInverted($this->is_inverted) 104 | ->setPriority($this->priority ?? 0) 105 | ->setStartsAt($this->starts_at) 106 | ->setEndsAt($this->ends_at) 107 | ->setParameters($this->parameters); 108 | } 109 | 110 | /** 111 | * @return Action[]|Collection 112 | */ 113 | public function getActiveActions() 114 | { 115 | return $this->actions 116 | ->filter(function (Action $action) { 117 | return $action->isActive(); 118 | }) 119 | ->sortBy('priority') 120 | ->values(); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Unit/Entities/Conditions/OneOfConditionTest.php: -------------------------------------------------------------------------------- 1 | makeTestCondition(\app(OneOfCondition::class)); 14 | } 15 | 16 | public function test_true_when_has_succeed_conditions() 17 | { 18 | $conditions = [ 19 | $this->failedChildrenCondition(new DummyAction()), 20 | $this->succeedChildrenCondition(new DummyAction()), 21 | $this->succeedChildrenCondition(new DummyAction()), 22 | ]; 23 | $this->target->addConditions(...$conditions); 24 | 25 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 26 | 27 | $this->assertTrue($result); 28 | $this->assertFired($conditions[0]); 29 | $this->assertFired($conditions[1]); 30 | $this->assertNotFired($conditions[2]); 31 | } 32 | 33 | public function test_false_when_no_succeeds() 34 | { 35 | $conditions = [ 36 | $this->failedChildrenCondition(new DummyAction()), 37 | $this->failedChildrenCondition(new DummyAction()), 38 | ]; 39 | $this->target->addConditions(...$conditions); 40 | 41 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 42 | 43 | $this->assertFalse($result); 44 | $this->assertFired(...$conditions); 45 | } 46 | 47 | public function test_collect_actions_of_first_succeed_condition() 48 | { 49 | $actions = $this->makeActions(4); 50 | $conditions = [ 51 | $this->failedChildrenCondition($actions[0]), 52 | $this->succeedChildrenCondition($actions[1]), 53 | $this->succeedChildrenCondition($actions[2]), 54 | ]; 55 | $this->target->addConditions(...$conditions); 56 | $this->testCondition->setActions([$this->action, $actions[3]]); 57 | 58 | $this->testCondition->check($this->target, $this->target->getInitialState()); 59 | 60 | $this->assertSame( 61 | [$actions[1], $this->action, $actions[3]], 62 | $this->testCondition->getActions() 63 | ); 64 | } 65 | 66 | public function test_dont_collect_actions_when_no_succeed_conditions() 67 | { 68 | $conditions = [ 69 | $this->failedChildrenCondition(new DummyAction()), 70 | $this->failedChildrenCondition(new DummyAction()), 71 | ]; 72 | $this->target->addConditions(...$conditions); 73 | 74 | $this->testCondition->check($this->target, $this->target->getInitialState()); 75 | 76 | $this->assertSame( 77 | [$this->action], 78 | $this->testCondition->getActions() 79 | ); 80 | } 81 | 82 | public function test_children_inverted_failed_condition_succeed() 83 | { 84 | $condition = $this->failedChildrenCondition(); 85 | $condition->setIsInverted(true); 86 | $this->target->addConditions($condition); 87 | 88 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 89 | 90 | $this->assertTrue($result); 91 | $this->assertFired($condition); 92 | } 93 | 94 | public function test_one_of_inverted_failed_condition_succeed() 95 | { 96 | $condition = $this->failedChildrenCondition(); 97 | $this->testCondition->setIsInverted(true); 98 | $this->target->addConditions($condition); 99 | 100 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 101 | 102 | $this->assertTrue($result); 103 | $this->assertFired($condition); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/Unit/Entities/Conditions/AllOfConditionTest.php: -------------------------------------------------------------------------------- 1 | makeTestCondition(\app(AllOfCondition::class)); 15 | } 16 | 17 | public function test_true_when_all_conditions_is_succeed() 18 | { 19 | /** @var DummyCondition[] $conditions */ 20 | $conditions = [ 21 | $this->succeedChildrenCondition(), 22 | $this->succeedChildrenCondition(), 23 | $this->succeedChildrenCondition(), 24 | ]; 25 | $this->target->addConditions(...$conditions); 26 | 27 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 28 | 29 | $this->assertTrue($result); 30 | $this->assertFired(...$conditions); 31 | } 32 | 33 | public function test_false_when_has_failed_condition() 34 | { 35 | /** @var DummyCondition[] $conditions */ 36 | $conditions = [ 37 | $this->succeedChildrenCondition(), 38 | $this->failedChildrenCondition(), 39 | $this->succeedChildrenCondition(), 40 | ]; 41 | $this->target->addConditions(...$conditions); 42 | 43 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 44 | 45 | $this->assertFalse($result); 46 | 47 | $this->assertFired($conditions[0]); 48 | $this->assertFired($conditions[1]); 49 | $this->assertNotFired($conditions[2]); 50 | } 51 | 52 | public function test_child_actions_collected() 53 | { 54 | $action1 = new DummyAction(); 55 | $action2 = new DummyAction(); 56 | /** @var DummyCondition[] $conditions */ 57 | $conditions = [ 58 | $this->succeedChildrenCondition(), 59 | $this->succeedChildrenCondition($action1), 60 | ]; 61 | $this->target->addConditions(...$conditions); 62 | $this->testCondition->setActions([$this->action, $action2]); 63 | 64 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 65 | 66 | $this->assertTrue($result); 67 | $this->assertSame( 68 | [$action1, $this->action, $action2], 69 | $this->testCondition->getActions() 70 | ); 71 | } 72 | 73 | public function test_child_actions_not_collected_when_failed() 74 | { 75 | /** @var DummyCondition[] $conditions */ 76 | $conditions = [ 77 | $this->succeedChildrenCondition(), 78 | $this->succeedChildrenCondition(new DummyAction()), 79 | $this->failedChildrenCondition(new DummyAction()), 80 | ]; 81 | $this->target->addConditions(...$conditions); 82 | 83 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 84 | 85 | $this->assertFalse($result); 86 | $this->assertSame([$this->action], $this->testCondition->getActions()); 87 | } 88 | 89 | public function test_children_inverted_failed_condition_succeed() 90 | { 91 | $condition = $this->failedChildrenCondition(); 92 | $condition->setIsInverted(true); 93 | $this->target->addConditions($condition); 94 | 95 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 96 | 97 | $this->assertTrue($result); 98 | $this->assertFired($condition); 99 | } 100 | 101 | public function test_all_of_inverted_failed_condition_succeed() 102 | { 103 | $this->testCondition->setIsInverted(true); 104 | $condition = $this->failedChildrenCondition(); 105 | $this->target->addConditions($condition); 106 | 107 | $result = $this->testCondition->check($this->target, $this->target->getInitialState()); 108 | 109 | $this->assertTrue($result); 110 | $this->assertFired($condition); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/Feature/Repositories/EloquentConditionRepositoryTest.php: -------------------------------------------------------------------------------- 1 | 'TrueCondition', 18 | 'priority' => 5, 19 | 'is_inverted' => true, 20 | 'starts_at' => '2019-01-01 10:00:00', 21 | 'ends_at' => '2019-01-02 20:00:00', 22 | ]; 23 | 24 | protected function setUp(): void 25 | { 26 | parent::setUp(); 27 | $this->conditionsRepository = \app(EloquentConditionRepository::class); 28 | } 29 | 30 | public function test_store() 31 | { 32 | $condition = $this->conditionsRepository->store($this->validAttributes); 33 | 34 | $this->assertIsNumeric($condition->getId()); 35 | $this->assertConditionAttributes($condition); 36 | } 37 | 38 | public function test_update() 39 | { 40 | /** @var Condition $initial */ 41 | $initial = \create(Condition::class, [ 42 | 'name' => 'AllOfCondition', 43 | 'priority' => 10, 44 | 'is_inverted' => false, 45 | 'starts_at' => '2018-10-10 00:00:00', 46 | 'ends_at' => '2018-11-10 00:00:00', 47 | ]); 48 | 49 | $condition = $this->conditionsRepository->update($initial->id, $this->validAttributes); 50 | 51 | $this->assertEquals($initial->id, $condition->getId()); 52 | $this->assertConditionAttributes($condition); 53 | } 54 | 55 | public function test_update_non_existent() 56 | { 57 | $count = Condition::query()->count(); 58 | 59 | $this->expectException(ConditionNotFoundException::class); 60 | $this->expectExceptionMessage('Condition 5 not found'); 61 | 62 | $this->conditionsRepository->update(5, $this->validAttributes); 63 | 64 | $this->assertEquals($count, Condition::query()->count()); 65 | } 66 | 67 | public function test_find() 68 | { 69 | /** @var Condition $initial */ 70 | $initial = \create(Condition::class, $this->validAttributes); 71 | 72 | $condition = $this->conditionsRepository->find($initial->id); 73 | 74 | $this->assertEquals($initial->id, $condition->getId()); 75 | $this->assertConditionAttributes($condition); 76 | } 77 | 78 | public function test_destroy() 79 | { 80 | /** @var Condition $initial */ 81 | $initial = \create(Condition::class, $this->validAttributes); 82 | 83 | $condition = $this->conditionsRepository->destroy($initial->id); 84 | 85 | /** @var Condition $deleted */ 86 | $deleted = Condition::query()->onlyTrashed()->find($initial->id); 87 | $this->assertNotNull($deleted); 88 | $this->assertTrue($deleted->trashed()); 89 | $this->assertEquals($deleted->id, $condition->getId()); 90 | $this->assertConditionAttributes($condition); 91 | } 92 | 93 | public function test_destroy_non_existent() 94 | { 95 | $count = Condition::query()->count(); 96 | 97 | $this->expectException(ConditionNotFoundException::class); 98 | $this->expectExceptionMessage('Condition 5 not found'); 99 | 100 | $this->conditionsRepository->destroy(5); 101 | 102 | $this->assertEquals($count, Condition::query()->count()); 103 | } 104 | 105 | private function assertConditionAttributes(\ConditionalActions\Contracts\ConditionContract $condition): void 106 | { 107 | $this->assertInstanceOf(TrueCondition::class, $condition); 108 | $this->assertEquals($this->validAttributes['priority'], $condition->getPriority()); 109 | $this->assertEquals($this->validAttributes['is_inverted'], $condition->isInverted()); 110 | $this->assertEquals($this->validAttributes['starts_at'], $condition->getStartsAt()->toDateTimeString()); 111 | $this->assertEquals($this->validAttributes['ends_at'], $condition->getEndsAt()->toDateTimeString()); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /tests/Feature/Traits/EloquentTargetTest.php: -------------------------------------------------------------------------------- 1 | create(); 19 | $children = \create(Condition::class, ['parent_id' => $roots[1]->id]); 20 | \create(Condition::class, ['parent_id' => $roots[1]->id]); 21 | $target->conditions()->saveMany([$roots[1], $children]); 22 | 23 | $actualRoots = $target->getRootConditions(); 24 | $actualChildren = $target->getChildrenConditions($roots[1]->id); 25 | 26 | $this->assertEquals( 27 | [$roots[1]->id], 28 | \iterator_to_array(\collect($actualRoots)->map->getId()) 29 | ); 30 | $this->assertEquals( 31 | [$children->id], 32 | \iterator_to_array(\collect($actualChildren)->map->getId()) 33 | ); 34 | } 35 | 36 | public function test_get_root_and_children_conditions_filtered_by_active() 37 | { 38 | /** @var DummyEloquentModel $target */ 39 | $target = \create(DummyEloquentModel::class); 40 | /** @var Condition $activeRoot */ 41 | $activeRoot = \create(Condition::class); 42 | $inactiveRoot = \create(Condition::class, [], 'inactive'); 43 | $inactiveChildren = $activeRoot->childrenConditions()->save(\create(Condition::class, [], 'inactive')); 44 | /** @var Condition $activeChildren */ 45 | $activeChildren = $activeRoot->childrenConditions()->save(\create(Condition::class)); 46 | $target->conditions()->saveMany([$activeRoot, $inactiveRoot, $activeChildren, $inactiveChildren]); 47 | 48 | $actualRoots = $target->getRootConditions(); 49 | $actualChildren = $target->getChildrenConditions($activeRoot->id); 50 | 51 | $this->assertEquals( 52 | [$activeRoot->id], 53 | \iterator_to_array(\collect($actualRoots)->map->getId()) 54 | ); 55 | $this->assertEquals( 56 | [$activeChildren->id], 57 | \iterator_to_array(\collect($actualChildren)->map->getId()) 58 | ); 59 | } 60 | 61 | public function test_get_root_and_children_conditions_sorted_by_priority() 62 | { 63 | /** @var DummyEloquentModel $target */ 64 | $target = \create(DummyEloquentModel::class); 65 | /** @var Condition $root10 */ 66 | [$root10, $root20, $root15] = \createMany(Condition::class, ['priority'], [[10], [20], [15]]); 67 | [$children10, $children20, $children15] = \createMany(Condition::class, ['priority'], [[10], [20], [15]]); 68 | $root10->childrenConditions()->saveMany([$children10, $children20, $children15]); 69 | $target->conditions()->saveMany([$root10, $root20, $root15, $children10, $children20, $children15]); 70 | 71 | $actualRoots = $target->getRootConditions(); 72 | $actualChildren = $target->getChildrenConditions($root10->id); 73 | 74 | $this->assertEquals( 75 | [$root10->id, $root15->id, $root20->id], 76 | \iterator_to_array(\collect($actualRoots)->map->getId()) 77 | ); 78 | $this->assertEquals( 79 | [$children10->id, $children15->id, $children20->id], 80 | \iterator_to_array(\collect($actualChildren)->map->getId()) 81 | ); 82 | } 83 | 84 | public function test_get_root_and_children_conditions_returns_condition_contracts() 85 | { 86 | /** @var DummyEloquentModel $target */ 87 | $target = \create(DummyEloquentModel::class); 88 | /** @var Condition $root */ 89 | $root = \create(Condition::class); 90 | $children = $root->childrenConditions()->save(\create(Condition::class, ['priority' => 10])); 91 | $target->conditions()->saveMany([$root, $children]); 92 | 93 | $actualRoots = $target->getRootConditions(); 94 | $actualChildren = $target->getChildrenConditions($root->id); 95 | 96 | $this->assertInstanceOf(ConditionContract::class, $actualRoots[0]); 97 | $this->assertInstanceOf(ConditionContract::class, $actualChildren[0]); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/Feature/Repositories/EloquentActionRepositoryTest.php: -------------------------------------------------------------------------------- 1 | 'UpdateStateAttributeAction', 20 | 'priority' => 5, 21 | 'parameters' => ['param' => 'value'], 22 | 'starts_at' => '2019-01-01 10:00:00', 23 | 'ends_at' => '2019-01-02 20:00:00', 24 | ]; 25 | 26 | protected function setUp(): void 27 | { 28 | parent::setUp(); 29 | 30 | $this->actionsRepository = \app(EloquentActionRepository::class); 31 | /** @var Condition $condition */ 32 | $condition = Condition::query()->create(['name' => 'TrueCondition']); 33 | $this->validAttributes['condition_id'] = $condition->id; 34 | } 35 | 36 | public function test_store() 37 | { 38 | $condition = $this->actionsRepository->store($this->validAttributes); 39 | 40 | $this->assertIsNumeric($condition->getId()); 41 | $this->assertConditionAttributes($condition); 42 | } 43 | 44 | public function test_update() 45 | { 46 | /** @var Condition $initial */ 47 | $initial = \create(Action::class, [ 48 | 'name' => 'UpdateStateAttributeAction', 49 | 'priority' => 10, 50 | 'condition_id' => $this->validAttributes['condition_id'], 51 | 'starts_at' => '2018-10-10 00:00:00', 52 | 'ends_at' => '2018-11-10 00:00:00', 53 | ]); 54 | 55 | $condition = $this->actionsRepository->update($initial->id, $this->validAttributes); 56 | 57 | $this->assertEquals($initial->id, $condition->getId()); 58 | $this->assertConditionAttributes($condition); 59 | } 60 | 61 | public function test_update_non_existent() 62 | { 63 | $count = Action::query()->count(); 64 | 65 | $this->expectException(ActionNotFoundException::class); 66 | $this->expectExceptionMessage('Action 5 not found'); 67 | 68 | $this->actionsRepository->update(5, $this->validAttributes); 69 | 70 | $this->assertEquals($count, Action::query()->count()); 71 | } 72 | 73 | public function test_find() 74 | { 75 | /** @var Action $initial */ 76 | $initial = \create(Action::class, $this->validAttributes); 77 | 78 | $action = $this->actionsRepository->find($initial->id); 79 | 80 | $this->assertEquals($initial->id, $action->getId()); 81 | $this->assertConditionAttributes($action); 82 | } 83 | 84 | public function test_destroy() 85 | { 86 | /** @var Condition $initial */ 87 | $initial = \create(Action::class, $this->validAttributes); 88 | 89 | $action = $this->actionsRepository->destroy($initial->id); 90 | 91 | /** @var Condition $deleted */ 92 | $deleted = Action::query()->onlyTrashed()->find($initial->id); 93 | $this->assertNotNull($deleted); 94 | $this->assertTrue($deleted->trashed()); 95 | $this->assertEquals($deleted->id, $action->getId()); 96 | $this->assertConditionAttributes($action); 97 | } 98 | 99 | public function test_destroy_non_existent() 100 | { 101 | $count = Action::query()->count(); 102 | 103 | $this->expectException(ActionNotFoundException::class); 104 | $this->expectExceptionMessage('Action 5 not found'); 105 | 106 | $this->actionsRepository->destroy(5); 107 | 108 | $this->assertEquals($count, Action::query()->count()); 109 | } 110 | 111 | private function assertConditionAttributes(ActionContract $condition): void 112 | { 113 | $this->assertInstanceOf(UpdateStateAttributeAction::class, $condition); 114 | $this->assertEquals($this->validAttributes['priority'], $condition->getPriority()); 115 | $this->assertEquals($this->validAttributes['parameters'], $condition->getParameters()); 116 | $this->assertEquals($this->validAttributes['starts_at'], $condition->getStartsAt()->toDateTimeString()); 117 | $this->assertEquals($this->validAttributes['ends_at'], $condition->getEndsAt()->toDateTimeString()); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/Feature/Entities/Eloquent/ConditionTest.php: -------------------------------------------------------------------------------- 1 | now)); 23 | } 24 | 25 | /** 26 | * @dataProvider provider_is_active 27 | * 28 | * @param bool $isActive 29 | * @param Carbon|null $startsAt 30 | * @param Carbon|null $endsAt 31 | */ 32 | public function test_is_active(bool $isActive, $startsAt, $endsAt) 33 | { 34 | /** @var Condition $condition */ 35 | $condition = \create(Condition::class, [ 36 | 'starts_at' => $startsAt, 37 | 'ends_at' => $endsAt, 38 | ]); 39 | 40 | $this->assertEquals($isActive, $condition->isActive()); 41 | } 42 | 43 | public function provider_is_active(): array 44 | { 45 | Carbon::setTestNow(Carbon::parse($this->now)); 46 | 47 | return [ 48 | 'starts_at at now and ends_at in future' => [true, Carbon::now(), Carbon::tomorrow()], 49 | 'starts_at in past and ends_at at now' => [true, Carbon::yesterday(), Carbon::now()], 50 | 'starts_at in past and ends_at in future' => [true, Carbon::yesterday(), Carbon::tomorrow()], 51 | 'starts_at is null and ends_at at now' => [true, null, Carbon::now()], 52 | 'starts_at at now and ends_at is null' => [true, Carbon::now(), null], 53 | 'starts_at is null and ends_at is null' => [true, null, null], 54 | 'starts_at in past and ends_at in past' => [false, Carbon::yesterday()->subDay(), Carbon::yesterday()], 55 | 'starts_at is null and ends_at in past' => [false, null, Carbon::yesterday()], 56 | 'starts_at in future and ends_at in future' => [false, Carbon::tomorrow(), Carbon::tomorrow()->addDay()], 57 | 'starts_at in future and ends_at is null' => [false, Carbon::tomorrow(), null], 58 | ]; 59 | } 60 | 61 | public function test_to_condition_exception_when_condition_not_exists() 62 | { 63 | /** @var Condition $condition */ 64 | $condition = \create(Condition::class, ['name' => 'NotExists']); 65 | 66 | $this->expectException(ConditionNotFoundException::class); 67 | $this->expectExceptionCode(Response::HTTP_NOT_FOUND); 68 | $this->expectExceptionMessage('Condition NotExists not found'); 69 | 70 | $condition->toCondition(); 71 | } 72 | 73 | public function test_to_condition_make_correct_condition() 74 | { 75 | /** @var Condition $condition */ 76 | $condition = \create(Condition::class, [ 77 | 'name' => 'TrueCondition', 78 | 'is_inverted' => true, 79 | 'parameters' => ['one' => 'first'], 80 | ]); 81 | /** @var Action $action */ 82 | $action = \create(Action::class); 83 | $condition->actions()->save($action); 84 | 85 | $actualCondition = $condition->toCondition(); 86 | 87 | $this->assertInstanceOf(TrueCondition::class, $actualCondition); 88 | $this->assertEquals($condition->id, $actualCondition->getId()); 89 | $this->assertEquals($condition->is_inverted, $actualCondition->isInverted()); 90 | $this->assertEquals($condition->parameters, $actualCondition->getParameters()); 91 | $this->assertEquals([$action->toAction()], $actualCondition->getActions()); 92 | } 93 | 94 | public function test_get_active_actions_filtered_not_active() 95 | { 96 | /** @var Condition $condition */ 97 | $condition = \create(Condition::class); 98 | /** @var Action $action */ 99 | $activeAction = \create(Action::class); 100 | $inactiveAction = \create(Action::class, ['ends_at' => Carbon::yesterday()]); 101 | $condition->actions()->saveMany([$activeAction, $inactiveAction]); 102 | 103 | $actions = $condition->getActiveActions(); 104 | 105 | $this->assertEquals([$activeAction->id], $actions->pluck('id')->toArray()); 106 | } 107 | 108 | public function test_get_active_actions_sorted_by_priority() 109 | { 110 | /** @var Condition $condition */ 111 | $condition = \create(Condition::class); 112 | [$action10, $action5, $action20] = \createMany(Action::class, ['priority'], [[10], [5], [20]]); 113 | $condition->actions()->saveMany([$action10, $action5, $action20]); 114 | 115 | $actions = $condition->getActiveActions(); 116 | 117 | $this->assertEquals( 118 | [$action5->id, $action10->id, $action20->id], 119 | $actions->pluck('id')->toArray() 120 | ); 121 | } 122 | 123 | public function test_validate_name() 124 | { 125 | $this->expectException(ValidationException::class); 126 | \create(Condition::class, ['name' => '']); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Entities/Conditions/BaseCondition.php: -------------------------------------------------------------------------------- 1 | id = $id; 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Gets condition identifier. 66 | * 67 | * @return int 68 | */ 69 | public function getId(): int 70 | { 71 | return $this->id; 72 | } 73 | 74 | /** 75 | * Sets parent identifier. 76 | * 77 | * @param int|null $parentId 78 | * 79 | * @return ConditionContract 80 | */ 81 | public function setParentId(?int $parentId): ConditionContract 82 | { 83 | $this->parentId = $parentId; 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * Gets parent identifier. 90 | * 91 | * @return int|null 92 | */ 93 | public function getParentId(): ?int 94 | { 95 | return $this->parentId; 96 | } 97 | 98 | /** 99 | * Sets inverted flag. 100 | * 101 | * @param bool|null $isInverted 102 | * 103 | * @return ConditionContract 104 | */ 105 | public function setIsInverted(?bool $isInverted): ConditionContract 106 | { 107 | $this->isInverted = $isInverted ?? false; 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * Sets the priority. 114 | * 115 | * @param int $priority 116 | * 117 | * @return BaseCondition 118 | */ 119 | public function setPriority(int $priority): self 120 | { 121 | $this->priority = $priority; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * Gets priority. 128 | * 129 | * @return int 130 | */ 131 | public function getPriority(): int 132 | { 133 | return $this->priority; 134 | } 135 | 136 | /** 137 | * Sets the starts time. 138 | * 139 | * @param Carbon|null $startsAt 140 | * 141 | * @return BaseCondition 142 | */ 143 | public function setStartsAt(?Carbon $startsAt): self 144 | { 145 | $this->startsAt = $startsAt; 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Gets starts time. 152 | * 153 | * @return Carbon|null 154 | */ 155 | public function getStartsAt(): ?Carbon 156 | { 157 | return $this->startsAt; 158 | } 159 | 160 | /** 161 | * Sets the finishes time. 162 | * 163 | * @param Carbon|null $endsAt 164 | * 165 | * @return BaseCondition 166 | */ 167 | public function setEndsAt(?Carbon $endsAt): self 168 | { 169 | $this->endsAt = $endsAt; 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * Gets finishes time. 176 | * 177 | * @return Carbon|null 178 | */ 179 | public function getEndsAt(): ?Carbon 180 | { 181 | return $this->endsAt; 182 | } 183 | 184 | /** 185 | * Determines whether this condition result should be inverted. 186 | * 187 | * @return bool 188 | */ 189 | public function isInverted(): bool 190 | { 191 | return $this->isInverted; 192 | } 193 | 194 | /** 195 | * Sets actions. 196 | * 197 | * @param iterable|null $actions 198 | * 199 | * @return ConditionContract 200 | */ 201 | public function setActions(?iterable $actions): ConditionContract 202 | { 203 | $this->actions = \collect($actions)->toArray(); 204 | 205 | return $this; 206 | } 207 | 208 | /** 209 | * Gets the condition actions. 210 | * 211 | * @return iterable|ActionContract[] 212 | */ 213 | public function getActions(): iterable 214 | { 215 | return $this->actions; 216 | } 217 | 218 | /** 219 | * Adds actions to actions queue. 220 | * 221 | * @param ActionContract ...$actions 222 | */ 223 | protected function addActions(ActionContract ...$actions) 224 | { 225 | $this->actions = \array_merge( 226 | (array) $this->actions, 227 | $actions 228 | ); 229 | } 230 | 231 | /** 232 | * Adds actions to actions queue. 233 | * 234 | * @param ActionContract ...$actions 235 | */ 236 | protected function prependActions(ActionContract ...$actions) 237 | { 238 | $this->actions = \array_merge( 239 | $actions, 240 | (array) $this->actions 241 | ); 242 | } 243 | 244 | /** 245 | * Sets the condition parameters. 246 | * 247 | * @param array $parameters 248 | * 249 | * @return ConditionContract 250 | */ 251 | public function setParameters(?array $parameters): ConditionContract 252 | { 253 | $this->parameters = $parameters ?? []; 254 | 255 | return $this; 256 | } 257 | 258 | /** 259 | * Gets condition parameters. 260 | * 261 | * @return iterable 262 | */ 263 | public function getParameters(): iterable 264 | { 265 | return $this->parameters; 266 | } 267 | 268 | public function toArray() 269 | { 270 | return [ 271 | 'id' => $this, 272 | ]; 273 | } 274 | 275 | protected function expectedResult(): bool 276 | { 277 | return !$this->isInverted(); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /docs/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | 3 | info: 4 | description: "Put your business logic to external storage." 5 | version: "1.0.0-alpha.1" 6 | title: "Conditional Actions API" 7 | contact: 8 | email: "a.paramonov@corp.my.com" 9 | license: 10 | name: "MIT" 11 | servers: 12 | - url: "/api/v1/conditional-actions/" 13 | 14 | paths: 15 | /conditions: 16 | post: 17 | tags: 18 | - "Conditions" 19 | summary: "Add a new condition to the store" 20 | operationId: "postCondition" 21 | requestBody: 22 | $ref: "#/components/requestBodies/ConditionRequest" 23 | responses: 24 | 200: 25 | $ref: "#/components/responses/ConditionResponse" 26 | 422: 27 | description: "Validation exception" 28 | 29 | /conditions/{conditionId}: 30 | parameters: 31 | - $ref: "#/components/parameters/conditionId" 32 | 33 | put: 34 | tags: 35 | - "Conditions" 36 | summary: "Update condition" 37 | operationId: "putCondition" 38 | requestBody: 39 | $ref: "#/components/requestBodies/ConditionRequest" 40 | responses: 41 | 200: 42 | $ref: "#/components/responses/ConditionResponse" 43 | 404: 44 | description: "Condition not found" 45 | 422: 46 | description: "Validation exception" 47 | 48 | get: 49 | tags: 50 | - "Conditions" 51 | summary: "Get condition" 52 | operationId: "getCondition" 53 | responses: 54 | 200: 55 | $ref: "#/components/responses/ConditionResponse" 56 | 404: 57 | description: "Condition not found" 58 | 59 | delete: 60 | tags: 61 | - "Conditions" 62 | summary: "Delete condition" 63 | operationId: "deleteCondition" 64 | responses: 65 | 200: 66 | $ref: "#/components/responses/ConditionResponse" 67 | 404: 68 | description: "Condition not found" 69 | 70 | /actions: 71 | post: 72 | tags: 73 | - "Actions" 74 | summary: "Add a new action to the condition" 75 | operationId: "postAction" 76 | requestBody: 77 | $ref: "#/components/requestBodies/ActionRequest" 78 | responses: 79 | 200: 80 | $ref: "#/components/responses/ActionResponse" 81 | 422: 82 | description: "Validation exception" 83 | 84 | /actions/{actionId}: 85 | parameters: 86 | - $ref: "#/components/parameters/actionId" 87 | 88 | put: 89 | tags: 90 | - "Actions" 91 | summary: "Update an action" 92 | operationId: "putAction" 93 | requestBody: 94 | $ref: "#/components/requestBodies/ActionRequest" 95 | responses: 96 | 200: 97 | $ref: "#/components/responses/ActionResponse" 98 | 404: 99 | description: "Action not found" 100 | 422: 101 | description: "Validation exception" 102 | 103 | get: 104 | tags: 105 | - "Actions" 106 | summary: "Get action" 107 | operationId: "getAction" 108 | responses: 109 | 200: 110 | $ref: "#/components/responses/ActionResponse" 111 | 404: 112 | description: "Action not found" 113 | 114 | delete: 115 | tags: 116 | - "Actions" 117 | summary: "Delete action" 118 | operationId: "deleteAction" 119 | responses: 120 | 200: 121 | $ref: "#/components/responses/ActionResponse" 122 | 404: 123 | description: "Action not found" 124 | 125 | components: 126 | requestBodies: 127 | ConditionRequest: 128 | content: 129 | application/json: 130 | schema: 131 | $ref: "#/components/schemas/Condition" 132 | 133 | ActionRequest: 134 | content: 135 | application/json: 136 | schema: 137 | $ref: "#/components/schemas/Action" 138 | 139 | schemas: 140 | 141 | # Models 142 | Condition: 143 | allOf: 144 | - type: "object" 145 | required: 146 | - "name" 147 | properties: 148 | name: 149 | type: "string" 150 | example: "AllOfCondition" 151 | is_inverted: 152 | type: "boolean" 153 | default: false 154 | example: false 155 | parameters: 156 | type: "object" 157 | - $ref: "#/components/schemas/priority" 158 | - $ref: "#/components/schemas/startsAt" 159 | - $ref: "#/components/schemas/endsAt" 160 | 161 | ConditionFull: 162 | allOf: 163 | - $ref: "#/components/schemas/Condition" 164 | - type: "object" 165 | properties: 166 | id: 167 | type: "integer" 168 | description: "Condition identifier" 169 | example: 1 170 | target_type: 171 | type: "string" 172 | description: "Type of target" 173 | example: "App\\Models\\Toy" 174 | target_id: 175 | type: "integer" 176 | 177 | Action: 178 | allOf: 179 | - type: "object" 180 | required: 181 | - "name" 182 | properties: 183 | name: 184 | type: "string" 185 | example: "DiscountAction" 186 | parameters: 187 | description: "Action parameters" 188 | type: "object" 189 | example: 190 | discount: 10 191 | - $ref: "#/components/schemas/priority" 192 | - $ref: "#/components/schemas/startsAt" 193 | - $ref: "#/components/schemas/endsAt" 194 | 195 | ActionFull: 196 | allOf: 197 | - $ref: "#/components/schemas/Action" 198 | - type: "object" 199 | properties: 200 | id: 201 | type: "integer" 202 | description: "Condition identifier" 203 | example: 1 204 | 205 | # Properties 206 | priority: 207 | type: "object" 208 | properties: 209 | priority: 210 | type: "integer" 211 | default: "0" 212 | example: 1 213 | 214 | startsAt: 215 | type: "object" 216 | properties: 217 | starts_at: 218 | description: "Start time" 219 | type: "string" 220 | format: "date-time" 221 | nullable: true 222 | default: "" 223 | example: "2019-05-01 10:00:00" 224 | 225 | endsAt: 226 | type: "object" 227 | properties: 228 | ends_at: 229 | description: "Finish time" 230 | type: "string" 231 | format: "date-time" 232 | default: "" 233 | nullable: true 234 | example: "2019-05-10 10:00:00" 235 | 236 | 237 | parameters: 238 | conditionId: 239 | name: "conditionId" 240 | in: "path" 241 | required: true 242 | description: "The condition identifier" 243 | schema: 244 | type: "integer" 245 | 246 | actionId: 247 | name: "actionId" 248 | in: "path" 249 | required: true 250 | description: "The action identifier" 251 | schema: 252 | type: "integer" 253 | 254 | responses: 255 | ConditionResponse: 256 | description: "Condition object" 257 | content: 258 | application/json: 259 | schema: 260 | $ref: "#/components/schemas/ConditionFull" 261 | 262 | ActionResponse: 263 | description: "Action object" 264 | content: 265 | application/json: 266 | schema: 267 | $ref: "#/components/schemas/ActionFull" 268 | -------------------------------------------------------------------------------- /tests/Feature/Http/Conditions/ConditionsControllerTest.php: -------------------------------------------------------------------------------- 1 | conditionRepositoryMock = Mockery::mock(ConditionRepository::class); 26 | $this->app->instance(ConditionRepository::class, $this->conditionRepositoryMock); 27 | 28 | ConditionalActions::routes(); 29 | } 30 | 31 | public function test_store_success() 32 | { 33 | $params = ['name' => 'TrueCondition']; 34 | $condition = $this->makeCondition(); 35 | $this->conditionRepositoryMock 36 | ->shouldReceive('store') 37 | ->once() 38 | ->with($params) 39 | ->andReturn($condition); 40 | 41 | $response = $this->postJson(\route('conditions.store'), $params); 42 | 43 | $this->assertResponse($response, $condition); 44 | } 45 | 46 | /** 47 | * @dataProvider provider_store_validation 48 | * 49 | * @param array $params 50 | * @param string ...$errorKeys 51 | */ 52 | public function test_store_validation(array $params = [], string ...$errorKeys) 53 | { 54 | $request = $this->postJson(\route('conditions.store', $params)); 55 | $request->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 56 | $this->assertValidationErrors($errorKeys, $request); 57 | } 58 | 59 | public function provider_store_validation(): array 60 | { 61 | return [ 62 | 'name omitted' => [[], 'name'], 63 | 'unknown name' => [['name' => 'UnknownCondition'], 'name'], 64 | 'is_inverted is not boolean' => [['name' => 'TrueCondition', 'is_inverted' => 'string'], 'is_inverted'], 65 | 'parameters is not array' => [['name' => 'TrueCondition', 'parameters' => 'string'], 'parameters'], 66 | 'priority is not array' => [['name' => 'TrueCondition', 'priority' => 'string'], 'priority'], 67 | 'starts_at is not a date' => [['name' => 'TrueCondition', 'starts_at' => 'string'], 'starts_at'], 68 | 'ends_at is not a date' => [['name' => 'TrueCondition', 'ends_at' => 'string'], 'ends_at'], 69 | ]; 70 | } 71 | 72 | public function test_update_success() 73 | { 74 | $params = ['name' => 'TrueCondition']; 75 | $condition = $this->makeCondition(); 76 | $this->conditionRepositoryMock 77 | ->shouldReceive('update') 78 | ->once() 79 | ->with($condition->getId(), $params) 80 | ->andReturn($condition); 81 | 82 | $response = $this->putJson(\route('conditions.update', $condition->getId()), $params); 83 | 84 | $this->assertResponse($response, $condition); 85 | } 86 | 87 | public function test_update_non_existent() 88 | { 89 | $params = ['name' => 'TrueCondition']; 90 | $exception = new ConditionNotFoundException('Condition not found'); 91 | $this->conditionRepositoryMock 92 | ->shouldReceive('update') 93 | ->once() 94 | ->with(5, $params) 95 | ->andThrow($exception); 96 | 97 | $response = $this->putJson(\route('conditions.update', 5), $params); 98 | 99 | $response->assertStatus(Response::HTTP_NOT_FOUND); 100 | $this->assertEquals($exception->getMessage(), $response->json('error.message')); 101 | } 102 | 103 | public function test_show_success() 104 | { 105 | $params = ['name' => 'TrueCondition']; 106 | $condition = $this->makeCondition(); 107 | $this->conditionRepositoryMock 108 | ->shouldReceive('find') 109 | ->once() 110 | ->with($condition->getId()) 111 | ->andReturn($condition); 112 | 113 | $response = $this->getJson(\route('conditions.show', $condition->getId()), $params); 114 | 115 | $this->assertResponse($response, $condition); 116 | } 117 | 118 | public function test_destroy_success() 119 | { 120 | $condition = $this->makeCondition(); 121 | $this->conditionRepositoryMock 122 | ->shouldReceive('destroy') 123 | ->once() 124 | ->with($condition->getId()) 125 | ->andReturn($condition); 126 | 127 | $response = $this->deleteJson(\route('conditions.destroy', $condition->getId())); 128 | 129 | $this->assertResponse($response, $condition); 130 | } 131 | 132 | public function test_destroy_non_existent() 133 | { 134 | $exception = new ConditionNotFoundException('Condition not found'); 135 | $this->conditionRepositoryMock 136 | ->shouldReceive('destroy') 137 | ->once() 138 | ->with(5) 139 | ->andThrow($exception); 140 | 141 | $response = $this->deleteJson(\route('conditions.destroy', 5)); 142 | 143 | $response->assertStatus(Response::HTTP_NOT_FOUND); 144 | $this->assertEquals($exception->getMessage(), $response->json('error.message')); 145 | } 146 | 147 | /** 148 | * @dataProvider provider_store_validation 149 | * 150 | * @param array $params 151 | * @param string ...$errorKeys 152 | */ 153 | public function test_update_validation(array $params = [], string ...$errorKeys) 154 | { 155 | $conditionId = 2; 156 | $request = $this->putJson(\route('conditions.update', $conditionId), $params); 157 | $request->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 158 | $this->assertValidationErrors($errorKeys, $request); 159 | } 160 | 161 | /** 162 | * @param array $errorKeys 163 | * @param TestResponse $request 164 | */ 165 | public function assertValidationErrors(array $errorKeys, TestResponse $request): void 166 | { 167 | $this->assertEquals( 168 | \collect($request->json('errors'))->keys()->sort()->values()->toArray(), 169 | \collect($errorKeys)->sort()->values()->toArray() 170 | ); 171 | } 172 | 173 | private function makeCondition(): BaseCondition 174 | { 175 | $condition = (new TrueCondition()) 176 | ->setId(3) 177 | ->setPriority(5) 178 | ->setIsInverted(true) 179 | ->setStartsAt(Carbon::yesterday()) 180 | ->setEndsAt(Carbon::tomorrow()); 181 | 182 | return $condition; 183 | } 184 | 185 | private function assertResponse(TestResponse $response, BaseCondition $condition): void 186 | { 187 | $response->assertOk(); 188 | $this->assertEquals('TrueCondition', $response->json('data.name')); 189 | $this->assertEquals($condition->getId(), $response->json('data.id')); 190 | $this->assertEquals($condition->isInverted(), $response->json('data.is_inverted')); 191 | $this->assertEquals($condition->getParameters(), $response->json('data.parameters')); 192 | $this->assertEquals($condition->getPriority(), $response->json('data.priority')); 193 | $this->assertEquals($condition->getStartsAt()->toIso8601String(), $response->json('data.starts_at')); 194 | $this->assertEquals($condition->getEndsAt()->toIso8601String(), $response->json('data.ends_at')); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/Feature/Http/Conditions/ActionsControllerTest.php: -------------------------------------------------------------------------------- 1 | 'UpdateStateAttributeAction', 'condition_id' => 10]; 22 | 23 | protected function setUp(): void 24 | { 25 | parent::setUp(); 26 | 27 | $this->actionRepositoryMock = Mockery::mock(ActionRepository::class); 28 | $this->app->instance(ActionRepository::class, $this->actionRepositoryMock); 29 | 30 | ConditionalActions::routes(); 31 | } 32 | 33 | public function test_store_success() 34 | { 35 | $action = $this->makeAction(); 36 | $this->actionRepositoryMock 37 | ->shouldReceive('store') 38 | ->once() 39 | ->with($this->validParams) 40 | ->andReturn($action); 41 | 42 | $response = $this->postJson(\route('actions.store'), $this->validParams); 43 | 44 | $this->assertResponse($response, $action); 45 | } 46 | 47 | /** 48 | * @dataProvider provider_store_validation 49 | * 50 | * @param array $params 51 | * @param string ...$errorKeys 52 | */ 53 | public function test_store_validation(array $params = [], string ...$errorKeys) 54 | { 55 | $request = $this->postJson(\route('actions.store', $params)); 56 | $request->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 57 | $this->assertValidationErrors($errorKeys, $request); 58 | } 59 | 60 | public function provider_store_validation(): array 61 | { 62 | return [ 63 | 'name omitted' => [ 64 | ['condition_id' => 10], 65 | 'name', 66 | ], 67 | 'unknown name' => [ 68 | ['condition_id' => 10, 'name' => 'UnknownAction'], 69 | 'name', 70 | ], 71 | 'condition_id omitted' => [ 72 | ['name' => 'UpdateStateAttributeAction'], 73 | 'condition_id', 74 | ], 75 | 'condition_id is not numeric' => [ 76 | ['condition_id' => 'string', 'name' => 'UpdateStateAttributeAction'], 77 | 'condition_id', 78 | ], 79 | 'parameters is not array' => [ 80 | ['condition_id' => 10, 'name' => 'UpdateStateAttributeAction', 'parameters' => 'string'], 81 | 'parameters', 82 | ], 83 | 'priority is not array' => [ 84 | ['condition_id' => 10, 'name' => 'UpdateStateAttributeAction', 'priority' => 'string'], 85 | 'priority', 86 | ], 87 | 'starts_at is not a date' => [ 88 | ['condition_id' => 10, 'name' => 'UpdateStateAttributeAction', 'starts_at' => 'string'], 89 | 'starts_at', 90 | ], 91 | 'ends_at is not a date' => [ 92 | ['condition_id' => 10, 'name' => 'UpdateStateAttributeAction', 'ends_at' => 'string'], 93 | 'ends_at', 94 | ], 95 | ]; 96 | } 97 | 98 | public function test_update_success() 99 | { 100 | $action = $this->makeAction(); 101 | $this->actionRepositoryMock 102 | ->shouldReceive('update') 103 | ->once() 104 | ->with($action->getId(), $this->validParams) 105 | ->andReturn($action); 106 | 107 | $response = $this->putJson(\route('actions.update', $action->getId()), $this->validParams); 108 | 109 | $this->assertResponse($response, $action); 110 | } 111 | 112 | public function test_update_non_existent() 113 | { 114 | $exception = new ActionNotFoundException('Action not found'); 115 | $this->actionRepositoryMock 116 | ->shouldReceive('update') 117 | ->once() 118 | ->with(5, $this->validParams) 119 | ->andThrow($exception); 120 | 121 | $response = $this->putJson(\route('actions.update', 5), $this->validParams); 122 | 123 | $response->assertStatus(Response::HTTP_NOT_FOUND); 124 | $this->assertEquals($exception->getMessage(), $response->json('error.message')); 125 | } 126 | 127 | public function test_show_success() 128 | { 129 | $action = $this->makeAction(); 130 | $this->actionRepositoryMock 131 | ->shouldReceive('find') 132 | ->once() 133 | ->with($action->getId()) 134 | ->andReturn($action); 135 | 136 | $response = $this->getJson(\route('actions.show', $action->getId()), $this->validParams); 137 | 138 | $this->assertResponse($response, $action); 139 | } 140 | 141 | public function test_destroy_success() 142 | { 143 | $action = $this->makeAction(); 144 | $this->actionRepositoryMock 145 | ->shouldReceive('destroy') 146 | ->once() 147 | ->with($action->getId()) 148 | ->andReturn($action); 149 | 150 | $response = $this->deleteJson(\route('actions.destroy', $action->getId())); 151 | 152 | $this->assertResponse($response, $action); 153 | } 154 | 155 | public function test_destroy_non_existent() 156 | { 157 | $exception = new ActionNotFoundException('Action not found'); 158 | $this->actionRepositoryMock 159 | ->shouldReceive('destroy') 160 | ->once() 161 | ->with(5) 162 | ->andThrow($exception); 163 | 164 | $response = $this->deleteJson(\route('actions.destroy', 5)); 165 | 166 | $response->assertStatus(Response::HTTP_NOT_FOUND); 167 | $this->assertEquals($exception->getMessage(), $response->json('error.message')); 168 | } 169 | 170 | /** 171 | * @dataProvider provider_store_validation 172 | * 173 | * @param array $params 174 | * @param string ...$errorKeys 175 | */ 176 | public function test_update_validation(array $params = [], string ...$errorKeys) 177 | { 178 | $actionId = 2; 179 | $request = $this->putJson(\route('actions.update', $actionId), $params); 180 | $request->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); 181 | $this->assertValidationErrors($errorKeys, $request); 182 | } 183 | 184 | /** 185 | * @param array $errorKeys 186 | * @param TestResponse $request 187 | */ 188 | public function assertValidationErrors(array $errorKeys, TestResponse $request): void 189 | { 190 | $this->assertEquals( 191 | \collect($request->json('errors'))->keys()->sort()->values()->toArray(), 192 | \collect($errorKeys)->sort()->values()->toArray() 193 | ); 194 | } 195 | 196 | private function makeAction(): BaseAction 197 | { 198 | $action = (new UpdateStateAttributeAction()) 199 | ->setId(3) 200 | ->setPriority(5) 201 | ->setParameters(['param1' => 'value']) 202 | ->setStartsAt(Carbon::yesterday()) 203 | ->setEndsAt(Carbon::tomorrow()); 204 | 205 | return $action; 206 | } 207 | 208 | private function assertResponse(TestResponse $response, BaseAction $action): void 209 | { 210 | $response->assertOk(); 211 | $this->assertEquals('UpdateStateAttributeAction', $response->json('data.name')); 212 | $this->assertEquals($action->getId(), $response->json('data.id')); 213 | $this->assertEquals($action->getParameters(), $response->json('data.parameters')); 214 | $this->assertEquals($action->getPriority(), $response->json('data.priority')); 215 | $this->assertEquals($action->getStartsAt()->toIso8601String(), $response->json('data.starts_at')); 216 | $this->assertEquals($action->getEndsAt()->toIso8601String(), $response->json('data.ends_at')); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | exclude(['bootstrap', 'database', 'storage', 'tests', 'vendor', 'app/Api']) 11 | ->notName('_ide_helper*.php') 12 | ->notName('server.php') 13 | ->in(__DIR__) 14 | ; 15 | 16 | return PhpCsFixer\Config::create() 17 | ->setRules([ 18 | '@PSR2' => true, 19 | 'align_multiline_comment' => true, 20 | 'array_indentation' => true, 21 | 'array_syntax' => ['syntax' => 'short'], 22 | 'blank_line_after_opening_tag' => true, 23 | 'blank_line_before_statement' => ['statements' => ['break', 'continue', 'declare', 'die', 'do', 'exit', 'for', 'foreach', 'goto', 'if', 'include', 'include_once', 'require', 'require_once', 'return', 'switch', 'throw', 'try', 'while', 'yield']], 24 | 'cast_spaces' => true, 25 | 'class_attributes_separation' => true, 26 | 'combine_consecutive_issets' => true, 27 | 'combine_consecutive_unsets' => true, 28 | 'compact_nullable_typehint' => true, 29 | 'dir_constant' => true, 30 | 'ereg_to_preg' => true, 31 | 'explicit_indirect_variable' => true, 32 | 'explicit_string_variable' => true, 33 | 'function_to_constant' => true, 34 | 'function_typehint_space' => true, 35 | 'include' => true, 36 | 'linebreak_after_opening_tag' => true, 37 | 'magic_constant_casing' => true, 38 | 'mb_str_functions' => true, 39 | 'method_chaining_indentation' => true, 40 | 'modernize_types_casting' => true, 41 | 'multiline_whitespace_before_semicolons' => true, 42 | 'native_function_casing' => true, 43 | 'native_function_invocation' => true, 44 | 'new_with_braces' => true, 45 | 'no_alias_functions' => true, 46 | 'no_alternative_syntax' => true, 47 | 'no_blank_lines_after_class_opening' => true, 48 | 'no_blank_lines_after_phpdoc' => true, 49 | 'no_empty_comment' => true, 50 | 'no_empty_phpdoc' => true, 51 | 'no_empty_statement' => true, 52 | 'no_extra_blank_lines' => true, 53 | 'no_homoglyph_names' => true, 54 | 'no_leading_import_slash' => true, 55 | 'no_leading_namespace_whitespace' => true, 56 | 'no_mixed_echo_print' => true, 57 | 'no_multiline_whitespace_around_double_arrow' => true, 58 | 'no_null_property_initialization' => true, 59 | 'no_php4_constructor' => true, 60 | 'no_short_bool_cast' => true, 61 | 'no_short_echo_tag' => true, 62 | 'no_singleline_whitespace_before_semicolons' => true, 63 | 'no_spaces_around_offset' => true, 64 | 'no_superfluous_elseif' => true, 65 | 'no_trailing_comma_in_list_call' => true, 66 | 'no_trailing_comma_in_singleline_array' => true, 67 | 'no_unneeded_control_parentheses' => true, 68 | 'no_unneeded_curly_braces' => true, 69 | 'no_unneeded_final_method' => true, 70 | 'no_unset_on_property' => true, 71 | 'no_unused_imports' => true, 72 | 'no_useless_else' => true, 73 | 'no_useless_return' => true, 74 | 'no_whitespace_before_comma_in_array' => true, 75 | 'no_whitespace_in_blank_line' => true, 76 | 'non_printable_character' => ['use_escape_sequences_in_strings' => true], 77 | 'normalize_index_brace' => true, 78 | 'object_operator_without_whitespace' => true, 79 | 'phpdoc_add_missing_param_annotation' => true, 80 | 'phpdoc_annotation_without_dot' => true, 81 | 'phpdoc_indent' => true, 82 | 'phpdoc_inline_tag' => true, 83 | 'phpdoc_no_access' => true, 84 | 'phpdoc_no_alias_tag' => true, 85 | 'phpdoc_no_empty_return' => true, 86 | 'phpdoc_no_package' => true, 87 | 'phpdoc_no_useless_inheritdoc' => true, 88 | 'phpdoc_order' => true, 89 | 'phpdoc_return_self_reference' => true, 90 | 'phpdoc_scalar' => true, 91 | 'phpdoc_separation' => true, 92 | 'phpdoc_single_line_var_spacing' => true, 93 | 'phpdoc_trim' => true, 94 | 'phpdoc_trim_consecutive_blank_line_separation' => true, 95 | 'phpdoc_types' => true, 96 | 'phpdoc_var_without_name' => true, 97 | 'psr4' => true, 98 | 'random_api_migration' => true, 99 | 'return_assignment' => true, 100 | 'return_type_declaration' => true, 101 | 'self_accessor' => true, 102 | 'semicolon_after_instruction' => true, 103 | 'set_type_to_cast' => true, 104 | 'short_scalar_cast' => true, 105 | 'simplified_null_return' => true, 106 | 'single_blank_line_before_namespace' => true, 107 | 'single_line_comment_style' => true, 108 | 'single_quote' => true, 109 | 'space_after_semicolon' => ['remove_in_empty_for_expressions' => true], 110 | 'standardize_increment' => true, 111 | 'standardize_not_equals' => true, 112 | 'strict_param' => true, 113 | 'ternary_operator_spaces' => true, 114 | 'ternary_to_null_coalescing' => true, 115 | 'trailing_comma_in_multiline_array' => true, 116 | 'trim_array_spaces' => true, 117 | 'unary_operator_spaces' => true, 118 | 'whitespace_after_comma_in_array' => true, 119 | 'yoda_style' => true, 120 | ]) 121 | ->setFinder($finder); 122 | 123 | /* 124 | This document has been generated with 125 | https://mlocati.github.io/php-cs-fixer-configurator/ 126 | you can change this configuration by importing this YAML code: 127 | 128 | fixerSets: 129 | - '@PSR2' 130 | fixers: 131 | align_multiline_comment: true 132 | array_indentation: true 133 | array_syntax: 134 | syntax: short 135 | blank_line_after_opening_tag: true 136 | blank_line_before_statement: 137 | statements: 138 | - break 139 | - continue 140 | - declare 141 | - die 142 | - do 143 | - exit 144 | - for 145 | - foreach 146 | - goto 147 | - if 148 | - include 149 | - include_once 150 | - require 151 | - require_once 152 | - return 153 | - switch 154 | - throw 155 | - try 156 | - while 157 | - yield 158 | cast_spaces: true 159 | class_attributes_separation: true 160 | combine_consecutive_issets: true 161 | combine_consecutive_unsets: true 162 | compact_nullable_typehint: true 163 | dir_constant: true 164 | ereg_to_preg: true 165 | explicit_indirect_variable: true 166 | explicit_string_variable: true 167 | function_to_constant: true 168 | function_typehint_space: true 169 | include: true 170 | linebreak_after_opening_tag: true 171 | magic_constant_casing: true 172 | mb_str_functions: true 173 | method_chaining_indentation: true 174 | modernize_types_casting: true 175 | multiline_whitespace_before_semicolons: true 176 | native_function_casing: true 177 | native_function_invocation: true 178 | new_with_braces: true 179 | no_alias_functions: true 180 | no_alternative_syntax: true 181 | no_blank_lines_after_class_opening: true 182 | no_blank_lines_after_phpdoc: true 183 | no_empty_comment: true 184 | no_empty_phpdoc: true 185 | no_empty_statement: true 186 | no_extra_blank_lines: true 187 | no_homoglyph_names: true 188 | no_leading_import_slash: true 189 | no_leading_namespace_whitespace: true 190 | no_mixed_echo_print: true 191 | no_multiline_whitespace_around_double_arrow: true 192 | no_null_property_initialization: true 193 | no_php4_constructor: true 194 | no_short_bool_cast: true 195 | no_short_echo_tag: true 196 | no_singleline_whitespace_before_semicolons: true 197 | no_spaces_around_offset: true 198 | no_superfluous_elseif: true 199 | no_trailing_comma_in_list_call: true 200 | no_trailing_comma_in_singleline_array: true 201 | no_unneeded_control_parentheses: true 202 | no_unneeded_curly_braces: true 203 | no_unneeded_final_method: true 204 | no_unset_on_property: true 205 | no_unused_imports: true 206 | no_useless_else: true 207 | no_useless_return: true 208 | no_whitespace_before_comma_in_array: true 209 | no_whitespace_in_blank_line: true 210 | non_printable_character: 211 | use_escape_sequences_in_strings: true 212 | normalize_index_brace: true 213 | object_operator_without_whitespace: true 214 | phpdoc_add_missing_param_annotation: true 215 | phpdoc_annotation_without_dot: true 216 | phpdoc_indent: true 217 | phpdoc_inline_tag: true 218 | phpdoc_no_access: true 219 | phpdoc_no_alias_tag: true 220 | phpdoc_no_empty_return: true 221 | phpdoc_no_package: true 222 | phpdoc_no_useless_inheritdoc: true 223 | phpdoc_order: true 224 | phpdoc_return_self_reference: true 225 | phpdoc_scalar: true 226 | phpdoc_separation: true 227 | phpdoc_single_line_var_spacing: true 228 | phpdoc_trim: true 229 | phpdoc_trim_consecutive_blank_line_separation: true 230 | phpdoc_types: true 231 | phpdoc_var_without_name: true 232 | psr4: true 233 | random_api_migration: true 234 | return_assignment: true 235 | return_type_declaration: true 236 | self_accessor: true 237 | semicolon_after_instruction: true 238 | set_type_to_cast: true 239 | short_scalar_cast: true 240 | simplified_null_return: true 241 | single_blank_line_before_namespace: true 242 | single_line_comment_style: true 243 | single_quote: true 244 | space_after_semicolon: 245 | remove_in_empty_for_expressions: true 246 | standardize_increment: true 247 | standardize_not_equals: true 248 | strict_param: true 249 | ternary_operator_spaces: true 250 | ternary_to_null_coalescing: true 251 | trailing_comma_in_multiline_array: true 252 | trim_array_spaces: true 253 | unary_operator_spaces: true 254 | whitespace_after_comma_in_array: true 255 | yoda_style: true 256 | risky: true 257 | 258 | */ 259 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Conditional Actions 2 | 3 | ![Build Status](https://travis-ci.org/my-com/laravel-conditional-actions.svg?branch=master) 4 | 5 | This package allows configuring business logic by API without changing your code. 6 | This is helpful when you don`t know specific conditions because they defined dynamically by your managers/users/etc. 7 | 8 | ## How it works 9 | 10 | Codebase provides predefined conditions, actions, targets and API for mix them into business logic to end users. 11 | Objects: 12 | * `Target` - provides all necessary data for conditions and actions; 13 | * `State` - key-value pairs. Actions should update state when applying; 14 | * `Condition` - condition has method `check`, it returns succeed it or not (bool); 15 | * `Action` - action has method `apply`, it change `State` or make any other actions and returns changed `State`; 16 | 17 | Lifecycle: 18 | * `Target` creates a `State` object; 19 | * `Target` gets all related active `Condition` sorted by priority and run the check on each condition; 20 | * For succeeded `Condition`, `Condition` gets all related actions and apply them to the `State`; 21 | * `Action` returns changed `State` which used in next conditions or actions; 22 | * After checking all `Condition`, `Target` gets new `State` to `applyState` method. You can use its state as you needed. 23 | 24 | ## Get started 25 | 26 | For example, you have a shop that sells toys. Your marketing runs some promotions for specific toys. 27 | If a user buys chests in the past or today is his birthday, "Barbie doll" should have a 10% discount. 28 | Promotion starts at 2019/05/01 at 00:00 and finishes at 2019/05/01 at 23:59. 29 | 30 | You should create: 31 | 32 | Conditions: 33 | * User bought toys in the past (`HasPaidToysCondition`) 34 | * Today is his birthday (`TodayIsBirthdayCondition`) 35 | 36 | Action: 37 | * "Barbie doll" should have a 10% discount (`DiscountAction`) 38 | 39 | For time restrictions (Promotion starts at 2019/05/01 00:00 and finishes at 2019/05/01 23:59) you can use fields `starts_at` and `ends_at`. 40 | 41 | Both conditions should be succeeded. You can use `AllOfCondition` condition from the package. 42 | 43 | Marketing can use it for promotions without changing your code. 44 | 45 | The final scheme for promotion: 46 | 47 | ``` 48 | ■ AllOfCondition (condition) 49 | │ # fields: ['id' => 1, 'starts_at' => '2019-05-01 00:00:00', 'ends_at' => '2019-05-01 23:59:59'] 50 | │ ║ 51 | │ ╚═» ░ DiscountAction (action) 52 | │ # fields: ['parameters' => ['discount' => 10]] 53 | │ 54 | ├─── ■ TodayIsBirthdayCondition (condition) 55 | │ # fields: ['parent_id' => 1] 56 | │ 57 | └─── ■ HasPaidToysCondition (condition) 58 | # fields: ['parent_id' => 1, 'parameters' => ['toy_id' => 5]] 59 | ``` 60 | 61 | Let`s go to implementation! 62 | 63 | ### Install package 64 | ```bash 65 | composer require my-com/laravel-conditional-actions 66 | ``` 67 | 68 | ### Laravel 69 | #### For versions < 5.5: 70 | Add package service provider to `config/app.php`: 71 | ```php 72 | return [ 73 | // ... 74 | 'providers' => [ 75 | // ... 76 | ConditionalActions\ConditionalActionsServiceProvider::class, 77 | ], 78 | // ... 79 | ``` 80 | #### For laravel >= 5.5 81 | Laravel 5.5 uses Package Auto-Discovery, so doesn't require you to manually add the ServiceProvider 82 | 83 | ### Lumen 84 | Register service provider and config in `app.php`: 85 | ```php 86 | $app->configure('conditional-actions'); 87 | $app->register(ConditionalActions\ConditionalActionsServiceProvider::class); 88 | ``` 89 | 90 | ### Add migrations 91 | ```bash 92 | php artisan ca:tables 93 | php artisan migrate 94 | php artisan vendor:publish --provider="ConditionalActions\ConditionalActionsServiceProvider" 95 | ``` 96 | 97 | > Command options: 98 | > ```bash 99 | > Description: 100 | > Create a migration for the conditional actions database tables 101 | > 102 | > Usage: 103 | > ca:tables [options] 104 | > 105 | > Options: 106 | > --migrations-path[=MIGRATIONS-PATH] Path to migrations directory (relative to framework base path) [default: "database/migrations"] 107 | > ``` 108 | 109 | ### Implement Target 110 | 111 | Target is an object that provides all necessary data for conditions and actions. It can be also an eloquent model. 112 | 113 | Since `Toy` - object for conditional actions, it should use `EloquentTarget` trait (trait has relationships and some method to get conditions for model) 114 | 115 | ```php 116 | class ToysPriceTarget implements TargetContract 117 | { 118 | use RunsConditionalActions; 119 | 120 | /** @var Toy */ 121 | public $toy; 122 | 123 | /** @var User */ 124 | public $user; 125 | 126 | public $finalPrice; 127 | 128 | public function __construct(Toy $toy, User $user) 129 | { 130 | $this->toy = $toy; 131 | $this->user = $user; 132 | } 133 | 134 | /** 135 | * Gets state from target. 136 | * 137 | * @return StateContract 138 | */ 139 | public function getInitialState(): StateContract 140 | { 141 | return $this->newState([ 142 | 'price' => $this->toy->price, 143 | ]); 144 | } 145 | 146 | /** 147 | * Sets the state to the target. 148 | * 149 | * @param StateContract $state 150 | */ 151 | public function applyState(StateContract $state): void 152 | { 153 | $this->finalPrice = $state->getAttribute('price'); 154 | } 155 | 156 | /** 157 | * Gets root target conditions. 158 | * 159 | * @return iterable|ConditionContract[] 160 | */ 161 | public function getRootConditions(): iterable 162 | { 163 | return $this->toy->getRootConditions(); 164 | } 165 | 166 | /** 167 | * Gets children target conditions. 168 | * 169 | * @param int $parentId 170 | * 171 | * @return iterable|ConditionContract[] 172 | */ 173 | public function getChildrenConditions(int $parentId): iterable 174 | { 175 | return $this->toy->getChildrenConditions($parentId); 176 | } 177 | } 178 | ``` 179 | 180 | ### Implement conditions 181 | 182 | Each condition should implement `ConditionalActions\Contracts\ConditionContract` contract. 183 | The package has a base abstract class `ConditionalActions\Entities\Conditions\BaseCondition` with all contract methods except the `check` method. 184 | 185 | ```php 186 | class HasPaidToysCondition extends BaseCondition 187 | { 188 | /** @var ToysService */ 189 | private $toysService; 190 | 191 | // You can use dependency injection in constructor 192 | public function __construct(ToysService $toysService) 193 | { 194 | $this->toysService = $toysService; 195 | } 196 | 197 | /** 198 | * Runs condition check. 199 | * 200 | * @param TargetContract $target 201 | * @param StateContract $state 202 | * 203 | * @return bool 204 | */ 205 | public function check(TargetContract $target, StateContract $state): bool 206 | { 207 | $toyId = $this->parameters['toy_id'] ?? null; 208 | 209 | if (!($target instanceof ToysPriceTarget) || is_null($toyId)) { 210 | return false; 211 | } 212 | 213 | return $this->toysService->hasPaidToy($target->user, $toyId); 214 | } 215 | } 216 | ``` 217 | 218 | ```php 219 | class TodayIsBirthdayCondition extends BaseCondition 220 | { 221 | /** @var ToysService */ 222 | private $toysService; 223 | 224 | // You can use dependency injection in constructor 225 | public function __construct(ToysService $toysService) 226 | { 227 | $this->toysService = $toysService; 228 | } 229 | 230 | /** 231 | * Runs condition check. 232 | * 233 | * @param TargetContract $target 234 | * @param StateContract $state 235 | * 236 | * @return bool 237 | */ 238 | public function check(TargetContract $target, StateContract $state): bool 239 | { 240 | if (!($target instanceof ToysPriceTarget)) { 241 | return false; 242 | } 243 | 244 | return Carbon::now()->isSameDay($target->user->birthday); 245 | } 246 | } 247 | ``` 248 | 249 | ### Implement action 250 | 251 | Each condition should implement `ConditionalActions\Contracts\ActionContract` contract. 252 | The package has a base abstract class `ConditionalActions\Entities\Actions\BaseAction` with all contract methods except the `apply` method. 253 | 254 | ```php 255 | class DiscountAction extends BaseAction 256 | { 257 | /** 258 | * Applies action to the state and returns a new state. 259 | * 260 | * @param StateContract $state 261 | * 262 | * @return StateContract 263 | */ 264 | public function apply(StateContract $state): StateContract 265 | { 266 | $discount = $this->parameters['discount'] ?? 0; 267 | $currentPrice = $state->getAttribute('price'); 268 | $state->setAttribute('price', $currentPrice - $currentPrice / 100 * $discount); 269 | 270 | return $state; 271 | } 272 | } 273 | ``` 274 | 275 | ### Add conditions to config `config/conditional-actions.php` 276 | 277 | ```php 278 | 279 | return [ 280 | 'conditions' => [ 281 | 'AllOfCondition' => ConditionalActions\Entities\Conditions\AllOfCondition::class, 282 | 'OneOfCondition' => ConditionalActions\Entities\Conditions\OneOfCondition::class, 283 | 'TrueCondition' => ConditionalActions\Entities\Conditions\TrueCondition::class, 284 | 285 | 'CurrentTimeCondition' => App\ConditionalActions\Conditions\CurrentTimeCondition::class, 286 | 'HasPaidToysCondition' => App\ConditionalActions\Conditions\HasPaidToysCondition::class, 287 | 'TodayIsBirthdayCondition' => App\ConditionalActions\Conditions\TodayIsBirthdayCondition::class, 288 | ], 289 | 'actions' => [ 290 | 'UpdateStateAttributeAction' => ConditionalActions\Entities\Actions\UpdateStateAttributeAction::class, 291 | 292 | 'DiscountAction' => App\ConditionalActions\Actions\DiscountAction::class, 293 | ], 294 | 'use_logger' => env('APP_DEBUG', false), 295 | ]; 296 | ``` 297 | 298 | ### Implement API for adds conditions and actions for `Toy` model 299 | 300 | You can use eloquent models or any other objects to put business logic into external storage. 301 | 302 | The package has basic CRUD for conditions and actions. You can enable it: 303 | ```php 304 | use ConditionalActions\ConditionalActions; 305 | use Illuminate\Support\ServiceProvider; 306 | 307 | class RouteServiceProvider extends ServiceProvider 308 | { 309 | // ... 310 | public function register() 311 | { 312 | ConditionalActions::routes(); 313 | } 314 | } 315 | ``` 316 | 317 | Or you can implement your own API. Sample example: 318 | ```php 319 | # This example is not an API. You can create API as you needed. 320 | 321 | /** @var Toy $toy */ 322 | $toy = Toy::find(10); 323 | 324 | /** @var Condition $allOf */ 325 | $allOf = $toy->conditions()->create([ 326 | 'name' => 'AllOfCondition', 327 | 'starts_at' => '2019-05-01 00:00:00', 328 | 'ends_at' => '2019-05-01 23:59:59', 329 | ]); 330 | 331 | $allOf->actions()->create([ 332 | 'name' => 'DiscountAction', 333 | 'parameters' => ['discount' => 10], 334 | ]); 335 | 336 | $todayIsBirthday = $allOf->childrenConditions()->make([ 337 | 'name' => 'TodayIsBirthdayCondition', 338 | ]); 339 | 340 | $hasPaidToy = $allOf->childrenConditions()->make([ 341 | 'name' => 'HasPaidToysCondition', 342 | 'parameters' => ['toy_id' => 5], 343 | ]); 344 | 345 | $toy->conditions()->saveMany([$allOf, $hasPaidToy, $todayIsBirthday]); 346 | ``` 347 | 348 | ### Run conditional actions 349 | 350 | ```php 351 | $toy = Toy::find(10); 352 | 353 | // Create a target instance 354 | $target = new ToysPriceTarget(Auth::user(), $toy); 355 | /* 356 | * Run conditional actions. 357 | * This method will iterate over all its conditions stored in database and apply actions related to succeed conditions 358 | */ 359 | $newState = $target->runConditionalActions(); 360 | dump($newState->getAttribute('price')); 361 | ``` 362 | 363 | ## P.S. 364 | 365 | The package includes conditions and actions: 366 | 367 | * Condition `AllOfCondition` - succeeded when **all** children conditions are succeeded. All children actions will be included to parent `AllOfCondition` condition; 368 | * Condition `OneOfCondition` - succeeded when **any of** children conditions are succeeded. All children actions for **first** succeeded condition will be included to parent `OneOfCondition` condition; 369 | * Condition `TrueCondition` - always succeeded; 370 | * Action `UpdateStateAttributeAction` - Updates an attribute value in the state. 371 | 372 | Both conditions and actions have fields: 373 | * `priority` - execution priority; 374 | * nullable `starts_at` and `ends_at` - enables condition or action at specific time period; 375 | * `parameters` - parameters of conditions or actions; 376 | * `is_inverted` - determines whether the condition result should be inverted. 377 | --------------------------------------------------------------------------------