Skip to content

Commit

Permalink
Merge pull request #6 from GuySartorelli/pulls/2/constraints-validator
Browse files Browse the repository at this point in the history
feat: Add validator that leverages symfony/validation constraints.
  • Loading branch information
GuySartorelli authored Mar 19, 2024
2 parents a5cf78c + e95f111 commit c81b7ca
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 42 deletions.
31 changes: 17 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
},
"require": {
"php": "^8.1",
"silverstripe/framework": "^5.1.0"
"silverstripe/framework": "^5.2"
},
"require-dev": {
"silverstripe/cms": "^5",
Expand Down
25 changes: 25 additions & 0 deletions docs/en/01-validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions src/Validators/ConstraintsValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace Signify\ComposableValidators\Validators;

use Signify\ComposableValidators\Traits\ValidatesMultipleFieldsWithConfig;
use SilverStripe\Forms\FormField;
use SilverStripe\Core\Validation\ConstraintValidator;

/**
* A validator which Validates values based on symfony validation constraints.
*
* Configuration values for this validator is an array of constraints to validate each field value against.
* For example:
* $validator->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;
}
}
13 changes: 12 additions & 1 deletion src/Validators/RegexFieldsValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Signify\ComposableValidators\Traits\ValidatesMultipleFieldsWithConfig;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Forms\FormField;

/**
Expand All @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions tests/php/ValidatorTests/ConstraintsValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace Signify\ComposableValidators\Tests;

use Signify\ComposableValidators\Validators\ConstraintsValidator;
use SilverStripe\Dev\SapphireTest;
use Symfony\Component\Validator\Constraints\Ip;
use Symfony\Component\Validator\Constraints\NotBlank;

class ConstraintsValidatorTest extends SapphireTest
{
public function provideValidation(): array
{
return [
[
'fields' => ['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']);
}
}
}
}
58 changes: 32 additions & 26 deletions tests/php/ValidatorTests/RegexFieldsValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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'
);

Expand Down

0 comments on commit c81b7ca

Please sign in to comment.