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 27, 2024
1 parent c651007 commit a2c8366
Show file tree
Hide file tree
Showing 95 changed files with 3,106 additions and 1,542 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace SilverStripe\Core\Validation\FieldValidation;

use RunTimeException;
use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator;
use SilverStripe\Core\Validation\ValidationResult;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace SilverStripe\Core\Validation\FieldValidation;

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

/**
* Validates that a value is a boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use InvalidArgumentException;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Core\Validation\FieldValidation\FieldValidator;
use SilverStripe\Core\Validation\FieldValidation\FieldValidationInterface;

/**
Expand Down
108 changes: 99 additions & 9 deletions src/Core/Validation/FieldValidation/DateFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,106 @@

namespace SilverStripe\Core\Validation\FieldValidation;

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

/**
* 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 +115,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']);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

namespace SilverStripe\Core\Validation\FieldValidation;

use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator;

/**
* Validates that a value is a valid date/time, which means that it follows the equivalent formats:
* - PHP date format Y-m-d H:i:s
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace SilverStripe\Core\Validation\FieldValidation;

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

/**
* Validates that a value is a valid decimal
Expand All @@ -23,7 +22,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
5 changes: 1 addition & 4 deletions src/Core/Validation/FieldValidation/EmailFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Email;
use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator;
use SilverStripe\Core\Validation\FieldValidation\SymfonyFieldValidatorTrait;
use SilverStripe\Core\Validation\FieldValidation\SymfonyFieldValidatorInterface;

/**
* Validates that a value is a valid email address
Expand All @@ -19,6 +16,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);
}
}
34 changes: 23 additions & 11 deletions src/Core/Validation/FieldValidation/FieldValidationTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use RuntimeException;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Validation\FieldValidation\FieldValidationInterface;
use SilverStripe\Core\Validation\ValidationResult;

/**
Expand All @@ -21,13 +20,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 +52,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 +77,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 +135,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
3 changes: 1 addition & 2 deletions src/Core/Validation/FieldValidation/IntFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
namespace SilverStripe\Core\Validation\FieldValidation;

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

/**
* 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
3 changes: 0 additions & 3 deletions src/Core/Validation/FieldValidation/IpFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Ip;
use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator;
use SilverStripe\Core\Validation\FieldValidation\SymfonyFieldValidatorTrait;
use SilverStripe\Core\Validation\FieldValidation\SymfonyFieldValidatorInterface;

/**
* Validates that a value is a valid IP address
Expand Down
3 changes: 0 additions & 3 deletions src/Core/Validation/FieldValidation/LocaleFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Locale;
use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator;
use SilverStripe\Core\Validation\FieldValidation\SymfonyFieldValidatorTrait;
use SilverStripe\Core\Validation\FieldValidation\SymfonyFieldValidatorInterface;

/**
* Validates that a value is a valid locale
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,28 @@

namespace SilverStripe\Core\Validation\FieldValidation;

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

/**
* Validates that that all values are one of a set of options
*/
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
10 changes: 4 additions & 6 deletions src/Core/Validation/FieldValidation/NumericFieldValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

namespace SilverStripe\Core\Validation\FieldValidation;

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

/**
* Validates that a value is a numeric value
Expand All @@ -26,7 +25,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 +38,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,21 @@
<?php

namespace SilverStripe\Core\Validation\FieldValidation;

use SilverStripe\Core\Validation\ValidationResult;

/**
* 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;
}
}
Loading

0 comments on commit a2c8366

Please sign in to comment.