Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add validator that leverages symfony/validation constraints. #6

Merged
merged 1 commit into from
Mar 19, 2024
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
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
Copy link
Owner Author

@GuySartorelli GuySartorelli Mar 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No point using a dedicated validator for this anymore - if you REALLY need regex validation, there's a constraint for that. But there may well be a constraint (or combination of constraints) to validate whatever you were validating in a cleaner way than regex (e.g. if you were checking IP addresses, use the Ip constraint) so check that before resorting to the regex constraint itself.

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
Loading