Skip to content

Commit c81b7ca

Browse files
Merge pull request #6 from GuySartorelli/pulls/2/constraints-validator
feat: Add validator that leverages symfony/validation constraints.
2 parents a5cf78c + e95f111 commit c81b7ca

File tree

7 files changed

+189
-42
lines changed

7 files changed

+189
-42
lines changed

README.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,23 @@ Displays a warning if some field(s) doesn't have a value. Useful for alerting us
3838
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).
3939
- **[`RequiredBlocksValidator`][14]**
4040
Require a specific [elemental block(s)][15] to exist in the `ElementalArea`, with optional minimum and maximum numbers of blocks and optional positional validation.
41-
- **[`RegexFieldsValidator`][16]**
41+
- **[`ConstraintsValidator`][16]**
42+
Validate values against [`symfony/validation` constraints](https://symfony.com/doc/current/reference/constraints.html). This is super powerful - definitely check it out.
43+
- **[`RegexFieldsValidator`][17]** (deprecated)
4244
Ensure some field(s) matches a specified regex pattern.
4345

44-
### [Abstract Validators][17]
46+
### [Abstract Validators][18]
4547

46-
- **[`BaseValidator`][18]**
48+
- **[`BaseValidator`][19]**
4749
Includes methods useful for getting the actual `FormField` and its label.
48-
- **[`FieldHasValueValidator`][19]**
50+
- **[`FieldHasValueValidator`][20]**
4951
Subclass of `BaseValidator`. Useful for validators that require logic to check if a field has any value or not.
5052

51-
## [Traits][20]
53+
## [Traits][21]
5254

53-
- **[`ValidatesMultipleFields`][21]**
55+
- **[`ValidatesMultipleFields`][22]**
5456
Useful for validators that can be fed an array of field names to be validated.
55-
- **[`ValidatesMultipleFieldsWithConfig`][22]**
57+
- **[`ValidatesMultipleFieldsWithConfig`][23]**
5658
Like `ValidatesMultipleFields` but requires a configuration array for each field to be validated.
5759

5860
[0]: docs/en/02-extensions.md
@@ -71,10 +73,11 @@ Like `ValidatesMultipleFields` but requires a configuration array for each field
7173
[13]: https://docs.silverstripe.org/en/developer_guides/model/searchfilters/
7274
[14]: docs/en/01-validators.md#requiredblocksvalidator
7375
[15]: https://github.com/silverstripe/silverstripe-elemental
74-
[16]: docs/en/01-validators.md#regexfieldsvalidator
75-
[17]: docs/en/01-validators.md#abstract-validators
76-
[18]: docs/en/01-validators.md#basevalidator
77-
[19]: docs/en/01-validators.md#fieldhasvaluevalidator
78-
[20]: docs/en/01-validators.md#traits
79-
[21]: docs/en/01-validators.md#validatesmultiplefields
80-
[22]: docs/en/01-validators.md#validatesmultiplefieldswithconfig
76+
[16]: docs/en/01-validators.md#constraintsvalidator
77+
[17]: docs/en/01-validators.md#regexfieldsvalidator
78+
[18]: docs/en/01-validators.md#abstract-validators
79+
[19]: docs/en/01-validators.md#basevalidator
80+
[20]: docs/en/01-validators.md#fieldhasvaluevalidator
81+
[21]: docs/en/01-validators.md#traits
82+
[22]: docs/en/01-validators.md#validatesmultiplefields
83+
[23]: docs/en/01-validators.md#validatesmultiplefieldswithconfig

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
},
3939
"require": {
4040
"php": "^8.1",
41-
"silverstripe/framework": "^5.1.0"
41+
"silverstripe/framework": "^5.2"
4242
},
4343
"require-dev": {
4444
"silverstripe/cms": "^5",

docs/en/01-validators.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,33 @@ The `ElementalArea` field holder template doesn't currently render validation er
218218

219219
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.
220220

221+
## ConstraintsValidator
222+
223+
This validator validates values against `symfony/validation` constraints, providing a wide range of well-tested and varied validation logic with a very simple API.
224+
225+
This is the ultimate one-stop-shop for form validation - just about any validation you want can be handled by this validator.
226+
227+
```php
228+
use Symfony\Component\Validator\Constraints\Ip;
229+
use Symfony\Component\Validator\Constraints\NotBlank;
230+
231+
ConstraintsValidator::create([
232+
// Must be an IP address or blank
233+
'IpAddress' => [new Ip()],
234+
// Must be an IP address and explicitly cannot be blank
235+
'IpAddressRequired' => [new Ip(), new NotBlank()],
236+
]);
237+
```
238+
239+
See the Symfony [validation constraints reference](https://symfony.com/doc/current/reference/constraints.html) for a list of contraints and their usage.
240+
241+
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.
242+
221243
## RegexFieldsValidator
222244

245+
> [!WARNING]
246+
> Deprecated! Use `ConstraintsValidator` with a [`Regex` constraint](https://symfony.com/doc/current/reference/constraints/Regex.html) instead.
247+
223248
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.
224249

225250
Any value that cannot be converted to a string cannot be checked against regex and so is ignored, and therefore implicitly passes validation.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace Signify\ComposableValidators\Validators;
4+
5+
use Signify\ComposableValidators\Traits\ValidatesMultipleFieldsWithConfig;
6+
use SilverStripe\Forms\FormField;
7+
use SilverStripe\Core\Validation\ConstraintValidator;
8+
9+
/**
10+
* A validator which Validates values based on symfony validation constraints.
11+
*
12+
* Configuration values for this validator is an array of constraints to validate each field value against.
13+
* For example:
14+
* $validator->addField(
15+
* 'IpAddress',
16+
* [
17+
* new Symfony\Component\Validator\Constraints\Ip(),
18+
* new Symfony\Component\Validator\Constraints\NotBlank()
19+
* ]
20+
* );
21+
*
22+
* See https://symfony.com/doc/current/reference/constraints.html for a list of constraints.
23+
*
24+
* This validator is best used within an AjaxCompositeValidator in conjunction with
25+
* a SimpleFieldsValidator.
26+
*/
27+
class ConstraintsValidator extends BaseValidator
28+
{
29+
use ValidatesMultipleFieldsWithConfig;
30+
31+
/**
32+
* Validates that the required blocks exist in the configured positions.
33+
*
34+
* @param array $data
35+
* @return bool
36+
*/
37+
public function php($data)
38+
{
39+
foreach ($this->getFields() as $fieldName => $constraint) {
40+
$value = isset($data[$fieldName]) ? $data[$fieldName] : null;
41+
$this->result->combineAnd(ConstraintValidator::validate($value, $constraint, $fieldName));
42+
}
43+
44+
return $this->result->isValid();
45+
}
46+
47+
protected function getValidationHintForField(FormField $field): ?array
48+
{
49+
// @TODO decide if there's a nice way to implement this
50+
return null;
51+
}
52+
}

src/Validators/RegexFieldsValidator.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Signify\ComposableValidators\Traits\ValidatesMultipleFieldsWithConfig;
66
use SilverStripe\Core\ClassInfo;
7+
use SilverStripe\Dev\Deprecation;
78
use SilverStripe\Forms\FormField;
89

910
/**
@@ -19,10 +20,20 @@
1920
*
2021
* This validator is best used within an AjaxCompositeValidator in conjunction with
2122
* a SimpleFieldsValidator.
23+
*
24+
* @deprecated 2.3.0 Use ConstraintsValidator instead.
2225
*/
2326
class RegexFieldsValidator extends BaseValidator
2427
{
25-
use ValidatesMultipleFieldsWithConfig;
28+
use ValidatesMultipleFieldsWithConfig {
29+
__construct as parentConstructor;
30+
}
31+
32+
public function __construct(array $fields = [])
33+
{
34+
Deprecation::notice('2.3.0', 'Use ConstraintsValidator instead', Deprecation::SCOPE_CLASS);
35+
$this->parentConstructor($fields);
36+
}
2637

2738
/**
2839
* Validates that the fields match their regular expressions.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace Signify\ComposableValidators\Tests;
4+
5+
use Signify\ComposableValidators\Validators\ConstraintsValidator;
6+
use SilverStripe\Dev\SapphireTest;
7+
use Symfony\Component\Validator\Constraints\Ip;
8+
use Symfony\Component\Validator\Constraints\NotBlank;
9+
10+
class ConstraintsValidatorTest extends SapphireTest
11+
{
12+
public function provideValidation(): array
13+
{
14+
return [
15+
[
16+
'fields' => ['FieldOne' => 'someValue'],
17+
'constraints' => ['FieldOne' => [new Ip()]],
18+
'isValid' => false,
19+
],
20+
[
21+
'fields' => ['FieldOne' => 'someValue'],
22+
'constraints' => ['FieldOne' => [new NotBlank()]],
23+
'isValid' => true,
24+
],
25+
];
26+
}
27+
28+
/**
29+
* @dataProvider provideValidation
30+
*/
31+
public function testValidation(array $fields, array $constraints, bool $isValid): void
32+
{
33+
$form = TestFormGenerator::getForm($fields, new ConstraintsValidator($constraints));
34+
$result = $form->validationResult();
35+
$this->assertSame($isValid, $result->isValid());
36+
$messages = $result->getMessages();
37+
if ($isValid) {
38+
$this->assertEmpty($messages);
39+
} else {
40+
$this->assertNotEmpty($messages);
41+
foreach ($messages as $message) {
42+
$this->assertSame(array_key_first($fields), $message['fieldName']);
43+
// It's up to the constraint what the message says, so testing it here could mean I have to update the
44+
// test if symfony changes their mind about it. For my purposes it's fine to just check that a message
45+
// exists
46+
$this->assertNotEmpty($message['message']);
47+
}
48+
}
49+
}
50+
}

tests/php/ValidatorTests/RegexFieldsValidatorTest.php

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Signify\ComposableValidators\Tests;
44

55
use Signify\ComposableValidators\Validators\RegexFieldsValidator;
6+
use SilverStripe\Dev\Deprecation;
67
use SilverStripe\Dev\SapphireTest;
78
use SilverStripe\Forms\FormField;
89
use SilverStripe\ORM\FieldType\DBField;
@@ -16,7 +17,7 @@ public function testValidationMessageIfRegexDoesntMatch(): void
1617
{
1718
$form = TestFormGenerator::getForm(
1819
['FieldOne' => 'value1'],
19-
new RegexFieldsValidator(['FieldOne' => ['/no match/']])
20+
Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/no match/']]))
2021
);
2122
$result = $form->validationResult();
2223
$this->assertFalse($result->isValid());
@@ -37,12 +38,12 @@ public function testValidationMessageConcatenation(): void
3738
{
3839
$form = TestFormGenerator::getForm(
3940
['FieldOne' => 'value1'],
40-
new RegexFieldsValidator([
41+
Deprecation::withNoReplacement(fn () => new RegexFieldsValidator([
4142
'FieldOne' => [
4243
'/no match/' => 'must not match',
4344
'/also not match/' => 'must pass testing',
4445
]
45-
])
46+
]))
4647
);
4748
$result = $form->validationResult();
4849
$this->assertFalse($result->isValid());
@@ -63,7 +64,7 @@ public function testNoValidationMessageIfRegexMatches(): void
6364
{
6465
$form = TestFormGenerator::getForm(
6566
['FieldOne' => 'value1'],
66-
new RegexFieldsValidator(['FieldOne' => ['/1$/']])
67+
Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/1$/']]))
6768
);
6869
$result = $form->validationResult();
6970
$this->assertTrue($result->isValid());
@@ -78,13 +79,13 @@ public function testNoValidationMessageIfRegexMatchesAny(): void
7879
{
7980
$form = TestFormGenerator::getForm(
8081
['FieldOne' => 'value1'],
81-
new RegexFieldsValidator([
82+
Deprecation::withNoReplacement(fn () => new RegexFieldsValidator([
8283
'FieldOne' => [
8384
'/no match/',
8485
'/1$/',
8586
'/no match 2/',
8687
],
87-
])
88+
]))
8889
);
8990
$result = $form->validationResult();
9091
$this->assertTrue($result->isValid());
@@ -99,7 +100,7 @@ public function testNoValidationMessageIfFieldMissing(): void
99100
{
100101
$form = TestFormGenerator::getForm(
101102
['FieldOne' => 'value1'],
102-
new RegexFieldsValidator(['MissingField' => ['/no match/']])
103+
Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['MissingField' => ['/no match/']]))
103104
);
104105
$result = $form->validationResult();
105106
$this->assertTrue($result->isValid());
@@ -114,7 +115,9 @@ public function testStringableObjectValue(): void
114115
{
115116
TestFormGenerator::getForm(
116117
['FieldOne'],
117-
$validator = new RegexFieldsValidator(['FieldOne' => ['/^Value1$/']])
118+
$validator = Deprecation::withNoReplacement(
119+
fn () => new RegexFieldsValidator(['FieldOne' => ['/^Value1$/']])
120+
)
118121
);
119122
$data = ['FieldOne' => DBField::create_field('Varchar', 'Value1')];
120123
// Valid when it matches.
@@ -137,7 +140,7 @@ public function testNullValue(): void
137140
{
138141
TestFormGenerator::getForm(
139142
['FieldOne'],
140-
$validator = new RegexFieldsValidator(['FieldOne' => ['/^$/']])
143+
$validator = Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/^$/']]))
141144
);
142145
$data = ['FieldOne' => null];
143146
// Valid when it matches.
@@ -160,7 +163,7 @@ public function testNumericValue(): void
160163
{
161164
TestFormGenerator::getForm(
162165
['FieldOne'],
163-
$validator = new RegexFieldsValidator(['FieldOne' => ['/12345/']])
166+
$validator = Deprecation::withNoReplacement(fn () => new RegexFieldsValidator(['FieldOne' => ['/12345/']]))
164167
);
165168
$data = ['FieldOne' => 12345];
166169
// Valid when it matches.
@@ -183,7 +186,9 @@ public function testNonStringableObjectValueIsIgnored(): void
183186
{
184187
TestFormGenerator::getForm(
185188
['FieldOne'],
186-
$validator = new RegexFieldsValidator(['FieldOne' => ['/no match/']])
189+
$validator = Deprecation::withNoReplacement(
190+
fn () => new RegexFieldsValidator(['FieldOne' => ['/no match/']])
191+
)
187192
);
188193
$valid = $validator->php(['FieldOne' => new TestUnstringable()]);
189194
$this->assertTrue($valid);
@@ -198,7 +203,9 @@ public function testArrayValueIsIgnored(): void
198203
{
199204
TestFormGenerator::getForm(
200205
['FieldOne'],
201-
$validator = new RegexFieldsValidator(['FieldOne' => ['/no match/']])
206+
$validator = Deprecation::withNoReplacement(
207+
fn () => new RegexFieldsValidator(['FieldOne' => ['/no match/']])
208+
)
202209
);
203210
$valid = $validator->php(['FieldOne' => ['Arbitrary value in an array']]);
204211
$this->assertTrue($valid);
@@ -212,26 +219,25 @@ public function testArrayValueIsIgnored(): void
212219
*/
213220
public function testValidationHints(): void
214221
{
222+
$configFields = [
223+
'Title' => [
224+
'/[a-z][A-Z]/' => 'contain any letter',
225+
],
226+
'Content' => [
227+
'/^some value$/',
228+
'/^[\d]$/',
229+
],
230+
'MissingField' => [
231+
'/^$/' => 'have no value',
232+
],
233+
];
215234
$form = TestFormGenerator::getForm(
216235
$formFields = [
217236
'NotValidated',
218237
'Title',
219238
'Content',
220239
],
221-
$validator = new RegexFieldsValidator(
222-
$configFields = [
223-
'Title' => [
224-
'/[a-z][A-Z]/' => 'contain any letter',
225-
],
226-
'Content' => [
227-
'/^some value$/',
228-
'/^[\d]$/',
229-
],
230-
'MissingField' => [
231-
'/^$/' => 'have no value',
232-
],
233-
]
234-
),
240+
$validator = Deprecation::withNoReplacement(fn () => new RegexFieldsValidator($configFields)),
235241
'Root.Test'
236242
);
237243

0 commit comments

Comments
 (0)