Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Docs/Documentation/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,40 @@ $this->addBehavior('CakeDC/Enum.Enum', [
]
]);
```

### Validation Configuration

By default the behavior only checks list values with an application rule (`isValid<Alias>`), 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<Name>()` method.
* `false` (default): no validation rule is added.

The rule is named `isValid<Alias>`, uses the list `errorMessage`, and is added after the rules of your `validation<Name>()` 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],
],
]);
```
81 changes: 73 additions & 8 deletions src/Model/Behavior/EnumBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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']`.
Expand All @@ -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,
* ],
* ];
* ```
Expand All @@ -71,6 +77,7 @@ class EnumBehavior extends Behavior
'defaultStrategy' => 'lookup',
'translate' => false,
'translationDomain' => 'default',
'validation' => false,
'implementedMethods' => [
'enum' => 'enum',
],
Expand Down Expand Up @@ -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<string, mixed> $config List configuration.
* @return array<string>
*/
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.
*
Expand All @@ -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']});
}

/**
Expand Down
151 changes: 151 additions & 0 deletions tests/TestCase/Model/Behavior/EnumBehaviorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -105,6 +119,7 @@ public static function provideBasicConfiguration(): array
'defaultStrategy' => 'lookup',
'translate' => false,
'translationDomain' => 'default',
'validation' => false,
'implementedMethods' => ['enum' => 'enum'],
'lists' => [
'priority' => [
Expand Down Expand Up @@ -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<string, mixed> $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 [
Expand Down
Loading