Skip to content

Commit

Permalink
ENH Use FieldValidators for FormField validation
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Nov 26, 2024
1 parent 26d5b11 commit 04e357a
Show file tree
Hide file tree
Showing 86 changed files with 3,042 additions and 1,514 deletions.
111 changes: 102 additions & 9 deletions src/Core/Validation/FieldValidation/DateFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,109 @@

namespace SilverStripe\Core\Validation\FieldValidation;

use SilverStripe\Core\Validation\FieldValidation\FieldValidator;
use Error;
use Exception;
use InvalidArgumentException;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator;

/**
* Validates that a value is a valid date, which means that it follows the equivalent formats:
* - PHP date format Y-m-d
* - SO format y-MM-dd i.e. DBDate::ISO_DATE
* - ISO format y-MM-dd i.e. DBDate::ISO_DATE
*
* Blank string values are allowed
*/
class DateFieldValidator extends FieldValidator
class DateFieldValidator extends StringFieldValidator
{
/**
* Minimum value as a date string
*/
private ?string $minValue = null;

/**
* Minimum value as a unix timestamp
*/
private ?int $minTimestamp = null;

/**
* Maximum value as a date string
*/
private ?string $maxValue = null;

/**
* Maximum value as a unix timestamp
*/
private ?int $maxTimestamp = null;

/**
* Converter to convert date strings to another format for display in error messages
*
* @var callable
*/
private $converter;

public function __construct(
string $name,
mixed $value,
?string $minValue = null,
?string $maxValue = null,
?callable $converter = null,
) {
// Convert Y-m-d args to timestamps
if (!is_null($minValue)) {
$this->minTimestamp = $this->dateToTimestamp($minValue);
}
if (!is_null($maxValue)) {
$this->maxTimestamp = $this->dateToTimestamp($maxValue);
}
if (!is_null($this->minTimestamp) && !is_null($this->maxTimestamp)
&& $this->maxTimestamp < $this->minTimestamp
) {
throw new InvalidArgumentException('maxValue cannot be less than minValue');
}
$this->minValue = $minValue;
$this->maxValue = $maxValue;
$this->converter = $converter;
parent::__construct($name, $value);
}

protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
// Allow empty strings
if ($this->value === '') {
$result = parent::validateValue();
// If the value is not a string, we can't validate it as a date
if (!$result->isValid()) {
return $result;
}
// Not using symfony/validator because it was allowing d-m-Y format strings
$date = date_parse_from_format($this->getFormat(), $this->value ?? '');
if ($date === false || $date['error_count'] > 0 || $date['warning_count'] > 0) {
// Validate value is a valid date
$timestamp = $this->dateToTimestamp($this->value ?? '');
if ($timestamp === false) {
$result->addFieldError($this->name, $this->getMessage());
return $result;
}
// Validate value is within range
if (!is_null($this->minTimestamp) && $timestamp < $this->minTimestamp) {
$minValue = $this->minValue;
if (!is_null($this->converter)) {
$minValue = call_user_func($this->converter, $this->minValue) ?: $this->minValue;
}
$message = _t(
__CLASS__ . '.TOOSMALL',
'Value cannot be older than {minValue}',
['minValue' => $minValue]
);
$result->addFieldError($this->name, $message);
} elseif (!is_null($this->maxTimestamp) && $timestamp > $this->maxTimestamp) {
$maxValue = $this->maxValue;
if (!is_null($this->converter)) {
$maxValue = call_user_func($this->converter, $this->maxValue) ?: $this->maxValue;
}
$message = _t(
__CLASS__ . '.TOOLARGE',
'Value cannot be newer than {maxValue}',
['maxValue' => $maxValue]
);
$result->addFieldError($this->name, $message);
}
return $result;
}
Expand All @@ -38,4 +118,17 @@ protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid date');
}

/**
* Parse a date string into a unix timestamp using the format specified by getFormat()
*/
private function dateToTimestamp(string $date): int|false
{
// Not using symfony/validator because it was allowing d-m-Y format strings
$date = date_parse_from_format($this->getFormat(), $date);
if ($date === false || $date['error_count'] > 0 || $date['warning_count'] > 0) {
return false;
}
return mktime($date['hour'], $date['minute'], $date['second'], $date['month'], $date['day'], $date['year']);
}
}
4 changes: 2 additions & 2 deletions src/Core/Validation/FieldValidation/DecimalFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace SilverStripe\Core\Validation\FieldValidation;

use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Core\Validation\FieldValidation\NumericFieldValidator;
use SilverStripe\Core\Validation\FieldValidation\NumericNonStringFieldValidator;

/**
* Validates that a value is a valid decimal
Expand All @@ -23,7 +23,7 @@
* 1234.9 - 4 digits the before the decimal point
* 999.999 - would be rounded to 1000.00 which exceeds 5 total digits
*/
class DecimalFieldValidator extends NumericFieldValidator
class DecimalFieldValidator extends NumericNonStringFieldValidator
{
/**
* Whole number size e.g. For Decimal(9,2) this would be 9
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ class EmailFieldValidator extends StringFieldValidator implements SymfonyFieldVa
public function getConstraint(): Constraint|array
{
$message = _t(__CLASS__ . '.INVALID', 'Invalid email address');
return new Email(message: $message);
return new Email(message: $message, mode: Email::VALIDATION_MODE_STRICT);
}
}
33 changes: 23 additions & 10 deletions src/Core/Validation/FieldValidation/FieldValidationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ trait FieldValidationTrait
*
* Each item in the array can be one of the following
* a) MyFieldValidator::class,
* b) MyFieldValidator::class => [null, 'getSomething'],
* c) MyFieldValidator::class => null,
* b) MyFieldValidator::class => ['argNameA' => null, 'argNameB' => 'getSomething'],
* d) MyFieldValidator::class => null,
*
* a) Will create a MyFieldValidator and pass the name and value of the field as args to the constructor
* b) Will create a MyFieldValidator and pass the name, value, and pass additional args, where each null values
* will be passed as null, and non-null values will call a method on the field e.g. will pass null for the first
* additional arg and call $field->getSomething() to get a value for the second additional arg
* Keys are used to speicify the arg name, which is done to prevents duplicate
* args being add to config when a subclass defines the same FieldValidator as a parent class.
* Note that keys are not named args, they are simply arbitary keys - though best practice is
* for the keys to match constructor argument names.
* c) Will disable a previously set MyFieldValidator. This is useful to disable a FieldValidator that was set
* on a parent class
*
Expand All @@ -49,6 +53,18 @@ public function validate(): ValidationResult
return $result;
}

/**
* Get the value of this field for use in validation via FieldValidators
*
* Intended to be overridden in subclasses when there is a need to provide something different
* from the value of the field itself, for instance DBComposite and CompositeField which need to
* provide a value that is a combination of the values of their children
*/
public function getValueForValidation(): mixed
{
return $this->getValue();
}

/**
* Get instantiated FieldValidators based on `field_validators` configuration
*/
Expand All @@ -62,11 +78,9 @@ private function getFieldValidators(): array
}
/** @var FieldValidationInterface|Configurable $this */
$name = $this->getName();
// For composite fields e.g. MyCompositeField[MySubField] we want to use the name of the composite field
$name = preg_replace('#\[[^\]]+\]$#', '', $name);
$value = $this->getValueForValidation();
// Field name is required for FieldValidators when called ValidationResult::addFieldMessage()
if ($name === '') {
throw new RuntimeException('Field name is blank');
}
$classes = $this->getClassesFromConfig();
foreach ($classes as $class => $argCalls) {
$args = [$name, $value];
Expand Down Expand Up @@ -122,10 +136,9 @@ private function getClassesFromConfig(): array
if (!is_array($argCalls)) {
throw new RuntimeException("argCalls for FieldValidator $class is not an array");
}
// array_unique() is used to dedupe any cases where a subclass defines the same FieldValidator
// this config can happens when a subclass defines a FieldValidator that was already defined on a parent
// class, though it calls different methods
$argCalls = array_unique($argCalls);
// Ensure that argCalls is a numerically indexed array
// as they may have been defined with string keys to prevent duplicate args
$argCalls = array_values($argCalls);
$classes[$class] = $argCalls;
}
foreach (array_keys($disabledClasses) as $class) {
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Validation/FieldValidation/IntFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
/**
* Validates that a value is a 32-bit signed integer
*/
class IntFieldValidator extends NumericFieldValidator
class IntFieldValidator extends NumericNonStringFieldValidator
{
/**
* The minimum value for a signed 32-bit integer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace SilverStripe\Core\Validation\FieldValidation;

use InvalidArgumentException;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Core\Validation\FieldValidation\OptionFieldValidator;

Expand All @@ -12,19 +11,20 @@
class MultiOptionFieldValidator extends OptionFieldValidator
{
/**
* @param mixed $value - an array of values to be validated
* @param mixed $value - an iterable set of values to be validated
*/
public function __construct(string $name, mixed $value, array $options)
{
if (!is_iterable($value) && !is_null($value)) {
throw new InvalidArgumentException('Value must be iterable');
}
parent::__construct($name, $value, $options);
}

protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
if (!is_iterable($this->value) && !is_null($this->value)) {
$result->addFieldError($this->name, $this->getMessage());
return $result;
}
foreach ($this->value as $value) {
$this->checkValueInOptions($value, $result);
}
Expand Down
7 changes: 3 additions & 4 deletions src/Core/Validation/FieldValidation/NumericFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function __construct(
string $name,
mixed $value,
?int $minValue = null,
?int $maxValue = null
?int $maxValue = null,
) {
if (!is_null($minValue) && !is_null($maxValue) && $maxValue < $minValue) {
throw new InvalidArgumentException('maxValue cannot be less than minValue');
Expand All @@ -39,9 +39,8 @@ public function __construct(
protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
if (!is_numeric($this->value) || is_string($this->value)) {
// Must be a numeric value, though not as a numeric string
$message = _t(__CLASS__ . '.WRONGTYPE', 'Must be numeric, and not a string');
if (!is_numeric($this->value)) {
$message = _t(__CLASS__ . '.NOTNUMERIC', 'Must be numeric');
$result->addFieldError($this->name, $message);
} elseif (!is_null($this->minValue) && $this->value < $this->minValue) {
$result->addFieldError($this->name, $this->getTooSmallMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace SilverStripe\Core\Validation\FieldValidation;

use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Core\Validation\FieldValidation\NumericFieldValidator;

/**
* Validates that a value is a numeric value and not a string
*/
class NumericNonStringFieldValidator extends NumericFieldValidator
{
protected function validateValue(): ValidationResult
{
$result = parent::validateValue();
if (is_numeric($this->value) && is_string($this->value)) {
$message = _t(__CLASS__ . '.WRONGTYPE', 'Must be numeric and not a string');
$result->addFieldError($this->name, $message);
}
return $result;
}
}
8 changes: 6 additions & 2 deletions src/Core/Validation/FieldValidation/OptionFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ protected function validateValue(): ValidationResult
protected function checkValueInOptions(mixed $value, ValidationResult $result): void
{
if (!in_array($value, $this->options, true)) {
$message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value');
$result->addFieldError($this->name, $message);
$result->addFieldError($this->name, $this->getMessage());
}
}

protected function getMessage(): string
{
return _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ protected function validateValue(): ValidationResult
if (!is_null($this->maxLength) && $len > $this->maxLength) {
$message = _t(
__CLASS__ . '.TOOLONG',
'Can not have more than {maxLength} characters',
'Cannot have more than {maxLength} characters',
['maxLength' => $this->maxLength]
);
$result->addFieldError($this->name, $message);
Expand Down
Loading

0 comments on commit 04e357a

Please sign in to comment.