diff --git a/Docs/Documentation/Configuration.md b/Docs/Documentation/Configuration.md index 7d0d4ed..5ffba73 100644 --- a/Docs/Documentation/Configuration.md +++ b/Docs/Documentation/Configuration.md @@ -80,3 +80,40 @@ $this->addBehavior('CakeDC/Enum.Enum', [ ] ]); ``` + +### Validation Configuration + +By default the behavior only checks list values with an application rule (`isValid`), which runs when the entity is saved. Disable it per list with `'applicationRules' => false`. + +Set the `validation` option to also add the check to a table validator, so invalid values are reported by `newEntity()` and `patchEntity()`: + +* `true`: adds the rule to the `default` validator. +* a validator name or an array of names, e.g. `'api'` or `['default', 'api']`: adds the rule to those validators. The table must define the matching `validation()` method. +* `false` (default): no validation rule is added. + +The rule is named `isValid`, uses the list `errorMessage`, and is added after the rules of your `validation()` method. When `allowEmpty` is `true`, the field is also allowed to be empty in the validator. + +```php +$this->addBehavior('CakeDC/Enum.Enum', ['lists' => [ + 'status' => [ + 'strategy' => 'const', + 'validation' => true, + 'errorMessage' => 'Invalid status', + ], + 'priority' => [ + 'validation' => ['default', 'api'], + ], +]]); +``` + +Set `validation` at the behavior level to enable it for all lists; a list can still override it: + +```php +$this->addBehavior('CakeDC/Enum.Enum', [ + 'validation' => true, + 'lists' => [ + 'status' => ['strategy' => 'const'], + 'priority' => ['validation' => false], + ], +]); +``` diff --git a/src/Model/Behavior/EnumBehavior.php b/src/Model/Behavior/EnumBehavior.php index 3f1ecc8..2fb749b 100644 --- a/src/Model/Behavior/EnumBehavior.php +++ b/src/Model/Behavior/EnumBehavior.php @@ -22,6 +22,7 @@ use Cake\ORM\RulesChecker; use Cake\Utility\Hash; use Cake\Utility\Inflector; +use Cake\Validation\Validator; use CakeDC\Enum\Model\Behavior\Exception\MissingEnumConfigurationException; use CakeDC\Enum\Model\Behavior\Exception\MissingEnumStrategyException; use CakeDC\Enum\Model\Behavior\Strategy\ConfigStrategy; @@ -40,6 +41,8 @@ class EnumBehavior extends Behavior * be translated. Defaults to `false`. * - `translationDomain`: Domain to use when translating list value. * Defaults to "default". + * - `validation`: Default value for the lists' `validation` option. + * Defaults to `false`. * - `nested`: (bool) If `true` the array returned by enum() method will be of form * `[['value' => 'v1', 'text' => 't1'], ['value' => 'v2', 'text' => 't2']` * instead of default `['v1' => 't1', 'v2' => 't2']`. @@ -60,7 +63,10 @@ class EnumBehavior extends Behavior * // Create application rule to ensure only valid enum value can be saved. * 'applicationRules' => true, * // Allow saving field without any enum value. - * 'allowEmpty' => false + * 'allowEmpty' => false, + * // Add a validation rule to the given validators. `true` means + * // `['default']`, a string or an array names the validators. + * 'validation' => false, * ], * ]; * ``` @@ -71,6 +77,7 @@ class EnumBehavior extends Behavior 'defaultStrategy' => 'lookup', 'translate' => false, 'translationDomain' => 'default', + 'validation' => false, 'implementedMethods' => [ 'enum' => 'enum', ], @@ -270,6 +277,70 @@ public function buildRules(EventInterface $event, RulesChecker $rules): void $event->setResult($rules); } + /** + * Adds a validation rule to the validator for each list enabling it. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Event. + * @param \Cake\Validation\Validator $validator Validator. + * @param string $name Validator name. + * @return void + */ + public function buildValidator(EventInterface $event, Validator $validator, string $name): void + { + foreach ($this->getConfig('lists') as $alias => $config) { + if (!in_array($name, $this->validatorNames($config), true)) { + continue; + } + + $field = $config['field']; + $validator->add($field, 'isValid' . Inflector::camelize($alias), [ + 'rule' => fn(mixed $value): bool => $this->isValidValue($alias, $value), + 'message' => $config['errorMessage'], + ]); + + if (Hash::get($config, 'allowEmpty') === true) { + $validator->allowEmptyString($field); + } + } + } + + /** + * Returns the names of the validators a list adds its rule to. + * + * @param array $config List configuration. + * @return array + */ + protected function validatorNames(array $config): array + { + $validation = $config['validation'] ?? $this->getConfig('validation'); + if ($validation === true) { + return ['default']; + } + + return $validation ? (array)$validation : []; + } + + /** + * Checks whether the value is a key of the list. + * + * @param string $alias List alias. + * @param mixed $value Value to check. + * @return bool + */ + protected function isValidValue(string $alias, mixed $value): bool + { + if (is_array($value)) { + $value = $value['value'] ?? ''; + } elseif ($value instanceof EntityInterface) { + $value = $value->get('value'); + } + + $config = $this->getConfig('lists.' . $alias); + $list = $this->strategy($alias, $config['strategy'])->enum($config); + + return array_key_exists($value, $list); + } + /** * Universal validation rule for lists. * @@ -296,14 +367,8 @@ public function __call(string $method, array $args): bool if (!$entity->hasValue($config['field']) && Hash::get($config, 'allowEmpty') === true) { return true; } - $value = $entity->{$config['field']}; - if (is_array($value)) { - $value = $value['value'] ?? ''; - } elseif ($value instanceof EntityInterface) { - $value = $value->get('value'); - } - return array_key_exists($value, $this->enum($alias)); + return $this->isValidValue($alias, $entity->{$config['field']}); } /** diff --git a/tests/TestCase/Model/Behavior/EnumBehaviorTest.php b/tests/TestCase/Model/Behavior/EnumBehaviorTest.php index b5fc4b2..c03280c 100644 --- a/tests/TestCase/Model/Behavior/EnumBehaviorTest.php +++ b/tests/TestCase/Model/Behavior/EnumBehaviorTest.php @@ -18,6 +18,7 @@ use Cake\ORM\Entity; use Cake\ORM\Table; use Cake\TestSuite\TestCase; +use Cake\Validation\Validator; use CakeDC\Enum\Model\Behavior\Strategy\AbstractStrategy; // phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses,Squiz.Classes.ClassFileName.NoMatch @@ -57,6 +58,19 @@ public function initialize(array $config): void } } +class ValidatedArticlesTable extends ArticlesTable +{ + public function initialize(array $config): void + { + $this->addBehavior('CakeDC/Enum.Enum', $config['enum']); + } + + public function validationCustom(Validator $validator): Validator + { + return $validator; + } +} + class ThirdPartyStrategy extends AbstractStrategy { public function enum(array $config = []): array @@ -105,6 +119,7 @@ public static function provideBasicConfiguration(): array 'defaultStrategy' => 'lookup', 'translate' => false, 'translationDomain' => 'default', + 'validation' => false, 'implementedMethods' => ['enum' => 'enum'], 'lists' => [ 'priority' => [ @@ -401,6 +416,142 @@ public function testTranslatedValues() $this->assertEquals($expected, $result); } + /** + * Builds an articles table with the Enum behavior using the given configuration. + * + * @param array $config Behavior configuration. + * @return \Cake\ORM\Table + */ + protected function articlesWithEnum(array $config): Table + { + $this->getTableLocator()->clear(); + + return $this->getTableLocator()->get('CakeDC/Enum.Articles', [ + 'className' => ValidatedArticlesTable::class, + 'table' => 'enum_articles', + 'enum' => $config, + ]); + } + + public function testValidationRejectsInvalidValue() + { + $Articles = $this->articlesWithEnum([ + 'lists' => [ + 'status' => ['strategy' => 'const', 'validation' => true], + ], + ]); + + $article = $Articles->newEntity(['status' => 'bogus']); + $expected = ['status' => ['isValidStatus' => 'The provided value is invalid']]; + $this->assertEquals($expected, $article->getErrors()); + + $article = $Articles->newEntity(['status' => 'DRAFT']); + $this->assertEmpty($article->getErrors()); + } + + public function testValidationDisabledByDefault() + { + $Articles = $this->articlesWithEnum([ + 'lists' => [ + 'status' => ['strategy' => 'const'], + ], + ]); + + $article = $Articles->newEntity(['status' => 'bogus']); + $this->assertEmpty($article->getErrors()); + } + + public function testValidationOnNamedValidatorOnly() + { + $Articles = $this->articlesWithEnum([ + 'lists' => [ + 'status' => ['strategy' => 'const', 'validation' => ['custom']], + ], + ]); + + $article = $Articles->newEntity(['status' => 'bogus']); + $this->assertEmpty($article->getErrors()); + + $article = $Articles->newEntity(['status' => 'bogus'], ['validate' => 'custom']); + $expected = ['status' => ['isValidStatus' => 'The provided value is invalid']]; + $this->assertEquals($expected, $article->getErrors()); + } + + public function testValidationFromBehaviorDefault() + { + $Articles = $this->articlesWithEnum([ + 'validation' => true, + 'lists' => [ + 'status' => ['strategy' => 'const'], + 'node_type' => ['strategy' => 'const', 'validation' => false], + ], + ]); + + $article = $Articles->newEntity(['status' => 'bogus', 'node_type' => 'bogus']); + $expected = ['status' => ['isValidStatus' => 'The provided value is invalid']]; + $this->assertEquals($expected, $article->getErrors()); + } + + public function testValidationAllowEmpty() + { + $Articles = $this->articlesWithEnum([ + 'lists' => [ + 'optional' => [ + 'strategy' => 'const', + 'lowercase' => true, + 'allowEmpty' => true, + 'validation' => true, + ], + ], + ]); + + $article = $Articles->newEntity(['optional' => '']); + $this->assertEmpty($article->getErrors()); + + $article = $Articles->newEntity(['optional' => 'bogus']); + $expected = ['optional' => ['isValidOptional' => 'The provided value is invalid']]; + $this->assertEquals($expected, $article->getErrors()); + } + + public function testValidationLookupStrategy() + { + $Articles = $this->articlesWithEnum([ + 'lists' => [ + 'priority' => ['prefix' => 'PRIORITY', 'errorMessage' => 'Invalid priority', 'validation' => true], + ], + ]); + + $article = $Articles->newEntity(['priority' => 'Urgent']); + $expected = ['priority' => ['isValidPriority' => 'Invalid priority']]; + $this->assertEquals($expected, $article->getErrors()); + + $article = $Articles->newEntity(['priority' => 'HIGH']); + $this->assertEmpty($article->getErrors()); + } + + public function testValidationIgnoresNestedAndTranslateOptions() + { + $Articles = $this->articlesWithEnum([ + 'nested' => true, + 'translate' => true, + 'lists' => [ + 'node_group' => ['strategy' => 'const', 'lowercase' => true, 'validation' => true], + ], + ]); + + $article = $Articles->newEntity(['node_group' => 'active']); + $this->assertEmpty($article->getErrors()); + } + + public function testBuildRulesWithNestedEnum() + { + $this->Articles->behaviors()->Enum->setConfig('nested', true); + + $article = new Entity(['status' => 'DRAFT', 'norules' => 'invalid']); + $this->Articles->save($article); + $this->assertArrayNotHasKey('status', $article->getErrors()); + } + public static function provideThirdPartyStrategy(): array { return [