diff --git a/README.md b/README.md index 3522c0e..b4e9afc 100644 --- a/README.md +++ b/README.md @@ -38,21 +38,23 @@ Displays a warning if some field(s) doesn't have a value. Useful for alerting us Uses [`SearchFilter`s][13] to define fields as required conditionally, based on the values of other fields (e.g. only required if `OtherField` has a value greater than 25). - **[`RequiredBlocksValidator`][14]** Require a specific [elemental block(s)][15] to exist in the `ElementalArea`, with optional minimum and maximum numbers of blocks and optional positional validation. -- **[`RegexFieldsValidator`][16]** +- **[`ConstraintsValidator`][16]** +Validate values against [`symfony/validation` constraints](https://symfony.com/doc/current/reference/constraints.html). This is super powerful - definitely check it out. +- **[`RegexFieldsValidator`][17]** (deprecated) Ensure some field(s) matches a specified regex pattern. -### [Abstract Validators][17] +### [Abstract Validators][18] -- **[`BaseValidator`][18]** +- **[`BaseValidator`][19]** Includes methods useful for getting the actual `FormField` and its label. -- **[`FieldHasValueValidator`][19]** +- **[`FieldHasValueValidator`][20]** Subclass of `BaseValidator`. Useful for validators that require logic to check if a field has any value or not. -## [Traits][20] +## [Traits][21] -- **[`ValidatesMultipleFields`][21]** +- **[`ValidatesMultipleFields`][22]** Useful for validators that can be fed an array of field names to be validated. -- **[`ValidatesMultipleFieldsWithConfig`][22]** +- **[`ValidatesMultipleFieldsWithConfig`][23]** Like `ValidatesMultipleFields` but requires a configuration array for each field to be validated. [0]: docs/en/02-extensions.md @@ -71,10 +73,11 @@ Like `ValidatesMultipleFields` but requires a configuration array for each field [13]: https://docs.silverstripe.org/en/developer_guides/model/searchfilters/ [14]: docs/en/01-validators.md#requiredblocksvalidator [15]: https://github.com/silverstripe/silverstripe-elemental -[16]: docs/en/01-validators.md#regexfieldsvalidator -[17]: docs/en/01-validators.md#abstract-validators -[18]: docs/en/01-validators.md#basevalidator -[19]: docs/en/01-validators.md#fieldhasvaluevalidator -[20]: docs/en/01-validators.md#traits -[21]: docs/en/01-validators.md#validatesmultiplefields -[22]: docs/en/01-validators.md#validatesmultiplefieldswithconfig +[16]: docs/en/01-validators.md#constraintsvalidator +[17]: docs/en/01-validators.md#regexfieldsvalidator +[18]: docs/en/01-validators.md#abstract-validators +[19]: docs/en/01-validators.md#basevalidator +[20]: docs/en/01-validators.md#fieldhasvaluevalidator +[21]: docs/en/01-validators.md#traits +[22]: docs/en/01-validators.md#validatesmultiplefields +[23]: docs/en/01-validators.md#validatesmultiplefieldswithconfig diff --git a/composer.json b/composer.json index 1ea82e6..4256fbd 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ }, "require": { "php": "^8.1", - "silverstripe/framework": "^5.1.0" + "silverstripe/framework": "^5.2" }, "require-dev": { "silverstripe/cms": "^5", diff --git a/docs/en/01-validators.md b/docs/en/01-validators.md index fba6549..4166b82 100644 --- a/docs/en/01-validators.md +++ b/docs/en/01-validators.md @@ -218,8 +218,33 @@ The `ElementalArea` field holder template doesn't currently render validation er This validator validates when the page (or other `DataObject` that has an `ElementalArea`) is saved or published - but not necessarily when the blocks within the `ElementalArea` are saved or published. This means content authors can work around the validation errors if they really want to. +## ConstraintsValidator + +This validator validates values against `symfony/validation` constraints, providing a wide range of well-tested and varied validation logic with a very simple API. + +This is the ultimate one-stop-shop for form validation - just about any validation you want can be handled by this validator. + +```php +use Symfony\Component\Validator\Constraints\Ip; +use Symfony\Component\Validator\Constraints\NotBlank; + +ConstraintsValidator::create([ + // Must be an IP address or blank + 'IpAddress' => [new Ip()], + // Must be an IP address and explicitly cannot be blank + 'IpAddressRequired' => [new Ip(), new NotBlank()], +]); +``` + +See the Symfony [validation constraints reference](https://symfony.com/doc/current/reference/constraints.html) for a list of contraints and their usage. + +See [validation using `symfony/validator` constraints](https://docs.silverstripe.org/en/developer_guides/model/validation/#symfony-validator) in the Silverstripe CMS documentation for any limitations imposed by Silverstripe CMS itself on this kind of validation. + ## RegexFieldsValidator +> [!WARNING] +> Deprecated! Use `ConstraintsValidator` with a [`Regex` constraint](https://symfony.com/doc/current/reference/constraints/Regex.html) instead. + This validator is used to require field values to match a specific regex pattern. Often it will make sense to have this validation inside a custom `FormField` implementation, but for one-off specific pattern validation of fields that don't warrant their own `FormField` this validator is perfect. It uses (so has all of the functionality and methods of) the [`ValidatesMultipleFieldsWithConfig`](#validatesmultiplefieldswithconfig) trait. Any value that cannot be converted to a string cannot be checked against regex and so is ignored, and therefore implicitly passes validation. diff --git a/src/Validators/ConstraintsValidator.php b/src/Validators/ConstraintsValidator.php new file mode 100644 index 0000000..b908fae --- /dev/null +++ b/src/Validators/ConstraintsValidator.php @@ -0,0 +1,52 @@ +addField( + * 'IpAddress', + * [ + * new Symfony\Component\Validator\Constraints\Ip(), + * new Symfony\Component\Validator\Constraints\NotBlank() + * ] + * ); + * + * See https://symfony.com/doc/current/reference/constraints.html for a list of constraints. + * + * This validator is best used within an AjaxCompositeValidator in conjunction with + * a SimpleFieldsValidator. + */ +class ConstraintsValidator extends BaseValidator +{ + use ValidatesMultipleFieldsWithConfig; + + /** + * Validates that the required blocks exist in the configured positions. + * + * @param array $data + * @return bool + */ + public function php($data) + { + foreach ($this->getFields() as $fieldName => $constraint) { + $value = isset($data[$fieldName]) ? $data[$fieldName] : null; + $this->result->combineAnd(ConstraintValidator::validate($value, $constraint, $fieldName)); + } + + return $this->result->isValid(); + } + + protected function getValidationHintForField(FormField $field): ?array + { + // @TODO decide if there's a nice way to implement this + return null; + } +} diff --git a/src/Validators/RegexFieldsValidator.php b/src/Validators/RegexFieldsValidator.php index 5bf79ac..ef75cea 100644 --- a/src/Validators/RegexFieldsValidator.php +++ b/src/Validators/RegexFieldsValidator.php @@ -4,6 +4,7 @@ use Signify\ComposableValidators\Traits\ValidatesMultipleFieldsWithConfig; use SilverStripe\Core\ClassInfo; +use SilverStripe\Dev\Deprecation; use SilverStripe\Forms\FormField; /** @@ -19,10 +20,20 @@ * * This validator is best used within an AjaxCompositeValidator in conjunction with * a SimpleFieldsValidator. + * + * @deprecated 2.3.0 Use ConstraintsValidator instead. */ class RegexFieldsValidator extends BaseValidator { - use ValidatesMultipleFieldsWithConfig; + use ValidatesMultipleFieldsWithConfig { + __construct as parentConstructor; + } + + public function __construct(array $fields = []) + { + Deprecation::notice('2.3.0', 'Use ConstraintsValidator instead', Deprecation::SCOPE_CLASS); + $this->parentConstructor($fields); + } /** * Validates that the fields match their regular expressions. diff --git a/tests/php/ValidatorTests/ConstraintsValidatorTest.php b/tests/php/ValidatorTests/ConstraintsValidatorTest.php new file mode 100644 index 0000000..6282d4d --- /dev/null +++ b/tests/php/ValidatorTests/ConstraintsValidatorTest.php @@ -0,0 +1,50 @@ + ['FieldOne' => 'someValue'], + 'constraints' => ['FieldOne' => [new Ip()]], + 'isValid' => false, + ], + [ + 'fields' => ['FieldOne' => 'someValue'], + 'constraints' => ['FieldOne' => [new NotBlank()]], + 'isValid' => true, + ], + ]; + } + + /** + * @dataProvider provideValidation + */ + public function testValidation(array $fields, array $constraints, bool $isValid): void + { + $form = TestFormGenerator::getForm($fields, new ConstraintsValidator($constraints)); + $result = $form->validationResult(); + $this->assertSame($isValid, $result->isValid()); + $messages = $result->getMessages(); + if ($isValid) { + $this->assertEmpty($messages); + } else { + $this->assertNotEmpty($messages); + foreach ($messages as $message) { + $this->assertSame(array_key_first($fields), $message['fieldName']); + // It's up to the constraint what the message says, so testing it here could mean I have to update the + // test if symfony changes their mind about it. For my purposes it's fine to just check that a message + // exists + $this->assertNotEmpty($message['message']); + } + } + } +} diff --git a/tests/php/ValidatorTests/RegexFieldsValidatorTest.php b/tests/php/ValidatorTests/RegexFieldsValidatorTest.php index 7e81fdd..4d18867 100644 --- a/tests/php/ValidatorTests/RegexFieldsValidatorTest.php +++ b/tests/php/ValidatorTests/RegexFieldsValidatorTest.php @@ -3,6 +3,7 @@ namespace Signify\ComposableValidators\Tests; use Signify\ComposableValidators\Validators\RegexFieldsValidator; +use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\SapphireTest; use SilverStripe\Forms\FormField; use SilverStripe\ORM\FieldType\DBField; @@ -16,7 +17,7 @@ public function testValidationMessageIfRegexDoesntMatch(): void { $form = TestFormGenerator::getForm( ['FieldOne' => 'value1'], - new RegexFieldsValidator(['FieldOne' => ['/no match/']]) + Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/no match/']])) ); $result = $form->validationResult(); $this->assertFalse($result->isValid()); @@ -37,12 +38,12 @@ public function testValidationMessageConcatenation(): void { $form = TestFormGenerator::getForm( ['FieldOne' => 'value1'], - new RegexFieldsValidator([ + Deprecation::withNoReplacement(fn () => new RegexFieldsValidator([ 'FieldOne' => [ '/no match/' => 'must not match', '/also not match/' => 'must pass testing', ] - ]) + ])) ); $result = $form->validationResult(); $this->assertFalse($result->isValid()); @@ -63,7 +64,7 @@ public function testNoValidationMessageIfRegexMatches(): void { $form = TestFormGenerator::getForm( ['FieldOne' => 'value1'], - new RegexFieldsValidator(['FieldOne' => ['/1$/']]) + Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/1$/']])) ); $result = $form->validationResult(); $this->assertTrue($result->isValid()); @@ -78,13 +79,13 @@ public function testNoValidationMessageIfRegexMatchesAny(): void { $form = TestFormGenerator::getForm( ['FieldOne' => 'value1'], - new RegexFieldsValidator([ + Deprecation::withNoReplacement(fn () => new RegexFieldsValidator([ 'FieldOne' => [ '/no match/', '/1$/', '/no match 2/', ], - ]) + ])) ); $result = $form->validationResult(); $this->assertTrue($result->isValid()); @@ -99,7 +100,7 @@ public function testNoValidationMessageIfFieldMissing(): void { $form = TestFormGenerator::getForm( ['FieldOne' => 'value1'], - new RegexFieldsValidator(['MissingField' => ['/no match/']]) + Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['MissingField' => ['/no match/']])) ); $result = $form->validationResult(); $this->assertTrue($result->isValid()); @@ -114,7 +115,9 @@ public function testStringableObjectValue(): void { TestFormGenerator::getForm( ['FieldOne'], - $validator = new RegexFieldsValidator(['FieldOne' => ['/^Value1$/']]) + $validator = Deprecation::withNoReplacement( + fn () => new RegexFieldsValidator(['FieldOne' => ['/^Value1$/']]) + ) ); $data = ['FieldOne' => DBField::create_field('Varchar', 'Value1')]; // Valid when it matches. @@ -137,7 +140,7 @@ public function testNullValue(): void { TestFormGenerator::getForm( ['FieldOne'], - $validator = new RegexFieldsValidator(['FieldOne' => ['/^$/']]) + $validator = Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/^$/']])) ); $data = ['FieldOne' => null]; // Valid when it matches. @@ -160,7 +163,7 @@ public function testNumericValue(): void { TestFormGenerator::getForm( ['FieldOne'], - $validator = new RegexFieldsValidator(['FieldOne' => ['/12345/']]) + $validator = Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/12345/']])) ); $data = ['FieldOne' => 12345]; // Valid when it matches. @@ -183,7 +186,9 @@ public function testNonStringableObjectValueIsIgnored(): void { TestFormGenerator::getForm( ['FieldOne'], - $validator = new RegexFieldsValidator(['FieldOne' => ['/no match/']]) + $validator = Deprecation::withNoReplacement( + fn () => new RegexFieldsValidator(['FieldOne' => ['/no match/']]) + ) ); $valid = $validator->php(['FieldOne' => new TestUnstringable()]); $this->assertTrue($valid); @@ -198,7 +203,9 @@ public function testArrayValueIsIgnored(): void { TestFormGenerator::getForm( ['FieldOne'], - $validator = new RegexFieldsValidator(['FieldOne' => ['/no match/']]) + $validator = Deprecation::withNoReplacement( + fn () => new RegexFieldsValidator(['FieldOne' => ['/no match/']]) + ) ); $valid = $validator->php(['FieldOne' => ['Arbitrary value in an array']]); $this->assertTrue($valid); @@ -212,26 +219,25 @@ public function testArrayValueIsIgnored(): void */ public function testValidationHints(): void { + $configFields = [ + 'Title' => [ + '/[a-z][A-Z]/' => 'contain any letter', + ], + 'Content' => [ + '/^some value$/', + '/^[\d]$/', + ], + 'MissingField' => [ + '/^$/' => 'have no value', + ], + ]; $form = TestFormGenerator::getForm( $formFields = [ 'NotValidated', 'Title', 'Content', ], - $validator = new RegexFieldsValidator( - $configFields = [ - 'Title' => [ - '/[a-z][A-Z]/' => 'contain any letter', - ], - 'Content' => [ - '/^some value$/', - '/^[\d]$/', - ], - 'MissingField' => [ - '/^$/' => 'have no value', - ], - ] - ), + $validator = Deprecation::withNoReplacement(fn () => new RegexFieldsValidator($configFields)), 'Root.Test' );