Skip to content

Commit bd8e82d

Browse files
committed
ENH Use FieldValidators for FormField validation
1 parent bbd8bb9 commit bd8e82d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+945
-1166
lines changed

src/Core/Validation/FieldValidation/DateFieldValidator.php

+101-4
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,113 @@
22

33
namespace SilverStripe\Core\Validation\FieldValidation;
44

5+
use Exception;
6+
use InvalidArgumentException;
57
use SilverStripe\Core\Validation\FieldValidation\FieldValidator;
68
use SilverStripe\Core\Validation\ValidationResult;
79

810
/**
911
* Validates that a value is a valid date, which means that it follows the equivalent formats:
1012
* - PHP date format Y-m-d
11-
* - SO format y-MM-dd i.e. DBDate::ISO_DATE
13+
* - ISO format y-MM-dd i.e. DBDate::ISO_DATE
1214
*
1315
* Blank string values are allowed
1416
*/
1517
class DateFieldValidator extends FieldValidator
1618
{
19+
/**
20+
* Minimum value as a date string
21+
*/
22+
private ?string $minValue;
23+
24+
/**
25+
* Minimum value as a unix timestamp
26+
*/
27+
private ?int $minTimestamp;
28+
29+
/**
30+
* Maximum value as a date string
31+
*/
32+
private ?string $maxValue;
33+
34+
/**
35+
* Maximum value as a unix timestamp
36+
*/
37+
private ?int $maxTimestamp;
38+
39+
/**
40+
* Converter to convert date strings to another format for display in error messages
41+
*
42+
* @var callable
43+
*/
44+
private $converter;
45+
46+
public function __construct(
47+
string $name,
48+
mixed $value,
49+
?string $minValue = null,
50+
?string $maxValue = null,
51+
?callable $converter = null,
52+
) {
53+
// Convert Y-m-d args to timestamps
54+
// Intermiediate variables are used to prevent "must not be accessed before initialization" PHP errors
55+
// when reading properties in the constructor
56+
$minTimestamp = null;
57+
$maxTimestamp = null;
58+
if (!is_null($minValue)) {
59+
$minTimestamp = $this->dateToTimestamp($minValue);
60+
}
61+
if (!is_null($maxValue)) {
62+
$maxTimestamp = $this->dateToTimestamp($maxValue);
63+
}
64+
if (!is_null($minTimestamp) && !is_null($maxTimestamp) && $minTimestamp < $maxTimestamp) {
65+
throw new InvalidArgumentException('maxValue cannot be less than minValue');
66+
}
67+
$this->minValue = $minValue;
68+
$this->maxValue = $maxValue;
69+
$this->minTimestamp = $minTimestamp;
70+
$this->maxTimestamp = $maxTimestamp;
71+
$this->converter = $converter;
72+
parent::__construct($name, $value);
73+
}
74+
1775
protected function validateValue(): ValidationResult
1876
{
1977
$result = ValidationResult::create();
2078
// Allow empty strings
2179
if ($this->value === '') {
2280
return $result;
2381
}
24-
// Not using symfony/validator because it was allowing d-m-Y format strings
25-
$date = date_parse_from_format($this->getFormat(), $this->value ?? '');
26-
if ($date === false || $date['error_count'] > 0 || $date['warning_count'] > 0) {
82+
// Validate value is a valid date
83+
try {
84+
$timestamp = $this->dateToTimestamp($this->value ?? '');
85+
} catch (Exception) {
2786
$result->addFieldError($this->name, $this->getMessage());
87+
return $result;
88+
}
89+
// Validate value is within range
90+
if (!is_null($this->minTimestamp) && $timestamp < $this->minTimestamp) {
91+
$minValue = $this->minValue;
92+
if (!is_null($this->converter)) {
93+
$minValue = call_user_func($this->converter, $this->minValue) ?: $this->minValue;
94+
}
95+
$message = _t(
96+
__CLASS__ . '.TOOSMALL',
97+
'Value cannot be less than {minValue}',
98+
['minValue' => $minValue]
99+
);
100+
$result->addFieldError($this->name, $message);
101+
} elseif (!is_null($this->maxTimestamp) && $timestamp > $this->maxTimestamp) {
102+
$maxValue = $this->maxValue;
103+
if (!is_null($this->converter)) {
104+
$maxValue = call_user_func($this->converter, $this->maxValue) ?: $this->maxValue;
105+
}
106+
$message = _t(
107+
__CLASS__ . '.TOOLARGE',
108+
'Value cannot be greater than {maxValue}',
109+
['maxValue' => $maxValue]
110+
);
111+
$result->addFieldError($this->name, $message);
28112
}
29113
return $result;
30114
}
@@ -38,4 +122,17 @@ protected function getMessage(): string
38122
{
39123
return _t(__CLASS__ . '.INVALID', 'Invalid date');
40124
}
125+
126+
/**
127+
* Parse a date string into a unix timestamp using the format specified by getFormat()
128+
*/
129+
private function dateToTimestamp(string $date): int
130+
{
131+
// Not using symfony/validator because it was allowing d-m-Y format strings
132+
$date = date_parse_from_format($this->getFormat(), $date);
133+
if ($date === false || $date['error_count'] > 0 || $date['warning_count'] > 0) {
134+
throw new InvalidArgumentException('Invalid date');
135+
}
136+
return mktime($date['hour'], $date['minute'], $date['second'], $date['month'], $date['day'], $date['year']);
137+
}
41138
}

src/Core/Validation/FieldValidation/FieldValidationTrait.php

+21-6
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ private function getFieldValidators(): array
6363
/** @var FieldValidationInterface|Configurable $this */
6464
$name = $this->getName();
6565
$value = $this->getValueForValidation();
66+
$classes = $this->getClassesFromConfig();
6667
// Field name is required for FieldValidators when called ValidationResult::addFieldMessage()
67-
if ($name === '') {
68+
if (count($classes) > 0 && $name === '') {
6869
throw new RuntimeException('Field name is blank');
6970
}
70-
$classes = $this->getClassesFromConfig();
7171
foreach ($classes as $class => $argCalls) {
7272
$args = [$name, $value];
7373
foreach ($argCalls as $i => $argCall) {
@@ -122,15 +122,30 @@ private function getClassesFromConfig(): array
122122
if (!is_array($argCalls)) {
123123
throw new RuntimeException("argCalls for FieldValidator $class is not an array");
124124
}
125-
// array_unique() is used to dedupe any cases where a subclass defines the same FieldValidator
126-
// this config can happens when a subclass defines a FieldValidator that was already defined on a parent
127-
// class, though it calls different methods
128-
$argCalls = array_unique($argCalls);
125+
$argCalls = $this->makeUnique($argCalls);
129126
$classes[$class] = $argCalls;
130127
}
131128
foreach (array_keys($disabledClasses) as $class) {
132129
unset($classes[$class]);
133130
}
134131
return $classes;
135132
}
133+
134+
/**
135+
* Dedupe any cases where a subclass defines the same FieldValidator
136+
* his config can happens when a subclass defines a FieldValidator that was already defined
137+
* on a parent class, though it calls different methods
138+
*/
139+
private function makeUnique(array $argCalls): array
140+
{
141+
// there may be multiple null values, which we need to retain so make them unqiue first
142+
// note that we can't use a short function in combination with $n++ in array_map as $n will always be 0
143+
$n = 0;
144+
$argCalls = array_map(function ($v) use (&$n) {
145+
return is_null($v) ? '{null}-' . $n++ : $v;
146+
}, $argCalls);
147+
$argCalls = array_unique($argCalls);
148+
$argCalls = array_map(fn($v) => str_contains($v, '{null}-') ? null : $v, $argCalls);
149+
return $argCalls;
150+
}
136151
}

src/Core/Validation/FieldValidation/MultiOptionFieldValidator.php

+5-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace SilverStripe\Core\Validation\FieldValidation;
44

5-
use InvalidArgumentException;
65
use SilverStripe\Core\Validation\ValidationResult;
76
use SilverStripe\Core\Validation\FieldValidation\OptionFieldValidator;
87

@@ -12,19 +11,20 @@
1211
class MultiOptionFieldValidator extends OptionFieldValidator
1312
{
1413
/**
15-
* @param mixed $value - an array of values to be validated
14+
* @param mixed $value - an iterable set of values to be validated
1615
*/
1716
public function __construct(string $name, mixed $value, array $options)
1817
{
19-
if (!is_iterable($value) && !is_null($value)) {
20-
throw new InvalidArgumentException('Value must be iterable');
21-
}
2218
parent::__construct($name, $value, $options);
2319
}
2420

2521
protected function validateValue(): ValidationResult
2622
{
2723
$result = ValidationResult::create();
24+
if (!is_iterable($this->value) && !is_null($this->value)) {
25+
$result->addFieldError($this->name, $this->getMessage());
26+
return $result;
27+
}
2828
foreach ($this->value as $value) {
2929
$this->checkValueInOptions($value, $result);
3030
}

src/Core/Validation/FieldValidation/NumericFieldValidator.php

+8-2
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ public function __construct(
3939
protected function validateValue(): ValidationResult
4040
{
4141
$result = ValidationResult::create();
42-
if (!is_numeric($this->value) || is_string($this->value)) {
43-
// Must be a numeric value, though not as a numeric string
42+
if (!is_numeric($this->value)) {
43+
$message = _t(__CLASS__ . '.NOTNUMERIC', 'Must be numeric');
44+
$result->addFieldError($this->name, $message);
45+
} elseif (is_numeric($this->value) && is_string($this->value)) {
46+
// This is a separate check from the the one above because for DBField the type is important
47+
// though for FormField form submissions values will usually have a string type
48+
// though should cast to the correct int or float type before validation
49+
// i.e. we don't want to tell CMS users to not use a string when they're using a TextField
4450
$message = _t(__CLASS__ . '.WRONGTYPE', 'Must be numeric, and not a string');
4551
$result->addFieldError($this->name, $message);
4652
} elseif (!is_null($this->minValue) && $this->value < $this->minValue) {

src/Core/Validation/FieldValidation/OptionFieldValidator.php

+6-2
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ protected function validateValue(): ValidationResult
3131
protected function checkValueInOptions(mixed $value, ValidationResult $result): void
3232
{
3333
if (!in_array($value, $this->options, true)) {
34-
$message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value');
35-
$result->addFieldError($this->name, $message);
34+
$result->addFieldError($this->name, $this->getMessage());
3635
}
3736
}
37+
38+
protected function getMessage(): string
39+
{
40+
return _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value');
41+
}
3842
}

src/Forms/CompositeField.php

+20-20
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace SilverStripe\Forms;
44

5+
use SilverStripe\Core\Validation\FieldValidation\CompositeFieldValidator;
56
use SilverStripe\Dev\Debug;
7+
use SilverStripe\Core\Validation\ValidationResult;
68

79
/**
810
* Base class for all fields that contain other fields.
@@ -13,6 +15,9 @@
1315
*/
1416
class CompositeField extends FormField
1517
{
18+
private static array $field_validators = [
19+
CompositeFieldValidator::class,
20+
];
1621

1722
/**
1823
* @var FieldList
@@ -52,6 +57,13 @@ class CompositeField extends FormField
5257

5358
protected $schemaComponent = 'CompositeField';
5459

60+
/**
61+
* Iterator used to name the field so that FieldValidators won't complain that the field is not named.
62+
*
63+
* @internal
64+
*/
65+
private static int $nameCounter = 0;
66+
5567
public function __construct($children = null)
5668
{
5769
// Normalise $children to a FieldList
@@ -63,8 +75,8 @@ public function __construct($children = null)
6375
$children = new FieldList($children);
6476
}
6577
$this->setChildren($children);
66-
67-
parent::__construct(null, false);
78+
$name = 'CompositeField_' . self::$nameCounter++;
79+
parent::__construct($name, false);
6880
}
6981

7082
/**
@@ -115,12 +127,17 @@ public function getChildren()
115127
return $this->children;
116128
}
117129

130+
public function getValueForValidation(): mixed
131+
{
132+
return $this->getChildren();
133+
}
134+
118135
/**
119136
* Returns the name (ID) for the element.
120137
* If the CompositeField doesn't have a name, but we still want the ID/name to be set.
121138
* This code generates the ID from the nested children.
122139
*/
123-
public function getName()
140+
public function getName(): string
124141
{
125142
if ($this->name) {
126143
return $this->name;
@@ -266,8 +283,6 @@ public function setForm($form)
266283
return $this;
267284
}
268285

269-
270-
271286
public function setDisabled($disabled)
272287
{
273288
parent::setDisabled($disabled);
@@ -522,19 +537,4 @@ public function debug(): string
522537
$result .= "</ul>";
523538
return $result;
524539
}
525-
526-
/**
527-
* Validate this field
528-
*
529-
* @param Validator $validator
530-
* @return bool
531-
*/
532-
public function validate($validator)
533-
{
534-
$valid = true;
535-
foreach ($this->children as $child) {
536-
$valid = ($child && $child->validate($validator) && $valid);
537-
}
538-
return $this->extendValidationResult($valid, $validator);
539-
}
540540
}

0 commit comments

Comments
 (0)