This commit is contained in:
Steve Boyd 2024-10-02 06:56:58 +00:00 committed by GitHub
commit 8299d329d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 870 additions and 133 deletions

View File

@ -20,6 +20,8 @@ SilverStripe\Core\Injector\Injector:
class: SilverStripe\ORM\FieldType\DBDecimal
Double:
class: SilverStripe\ORM\FieldType\DBDouble
Email:
class: SilverStripe\ORM\FieldType\DBEmail
Enum:
class: SilverStripe\ORM\FieldType\DBEnum
Float:

View File

@ -47,6 +47,7 @@
"symfony/dom-crawler": "^7.0",
"symfony/filesystem": "^7.0",
"symfony/http-foundation": "^7.0",
"symfony/intl": "^7.0",
"symfony/mailer": "^7.0",
"symfony/mime": "^7.0",
"symfony/translation": "^7.0",

View File

@ -35,9 +35,9 @@ class ConstraintValidator
/** @var ConstraintViolationInterface $violation */
foreach ($violations as $violation) {
if ($fieldName) {
$result->addFieldError($fieldName, $violation->getMessage());
$result->addFieldError($fieldName, $violation->getMessage(), value: $value);
} else {
$result->addError($violation->getMessage());
$result->addError($violation->getMessage(), value: $value);
}
}

View File

@ -46,6 +46,11 @@ class ValidationResult
*/
const CAST_TEXT = 'text';
/**
* Default value of $value parameter
*/
private const VALUE_UNSET = '_VALUE_UNSET_';
/**
* Is the result valid or not.
* Note that there can be non-error messages in the list.
@ -71,11 +76,17 @@ class ValidationResult
* This can be usedful for ensuring no duplicate messages
* @param string|bool $cast Cast type; One of the CAST_ constant definitions.
* Bool values will be treated as plain text flag.
* @param mixed $value The value that failed validation
* @return $this
*/
public function addError($message, $messageType = ValidationResult::TYPE_ERROR, $code = null, $cast = ValidationResult::CAST_TEXT)
{
return $this->addFieldError(null, $message, $messageType, $code, $cast);
public function addError(
$message,
$messageType = ValidationResult::TYPE_ERROR,
$code = null,
$cast = ValidationResult::CAST_TEXT,
$value = ValidationResult::VALUE_UNSET,
) {
return $this->addFieldError(null, $message, $messageType, $code, $cast, $value);
}
/**
@ -89,6 +100,7 @@ class ValidationResult
* This can be usedful for ensuring no duplicate messages
* @param string|bool $cast Cast type; One of the CAST_ constant definitions.
* Bool values will be treated as plain text flag.
* @param mixed $value The value that failed validation
* @return $this
*/
public function addFieldError(
@ -96,10 +108,11 @@ class ValidationResult
$message,
$messageType = ValidationResult::TYPE_ERROR,
$code = null,
$cast = ValidationResult::CAST_TEXT
$cast = ValidationResult::CAST_TEXT,
$value = ValidationResult::VALUE_UNSET,
) {
$this->isValid = false;
return $this->addFieldMessage($fieldName, $message, $messageType, $code, $cast);
return $this->addFieldMessage($fieldName, $message, $messageType, $code, $cast, $value);
}
/**
@ -112,11 +125,17 @@ class ValidationResult
* This can be usedful for ensuring no duplicate messages
* @param string|bool $cast Cast type; One of the CAST_ constant definitions.
* Bool values will be treated as plain text flag.
* @param mixed $value The value that failed validation
* @return $this
*/
public function addMessage($message, $messageType = ValidationResult::TYPE_ERROR, $code = null, $cast = ValidationResult::CAST_TEXT)
{
return $this->addFieldMessage(null, $message, $messageType, $code, $cast);
public function addMessage(
$message,
$messageType = ValidationResult::TYPE_ERROR,
$code = null,
$cast = ValidationResult::CAST_TEXT,
$value = ValidationResult::VALUE_UNSET,
) {
return $this->addFieldMessage(null, $message, $messageType, $code, $cast, $value);
}
/**
@ -130,6 +149,7 @@ class ValidationResult
* This can be usedful for ensuring no duplicate messages
* @param string|bool $cast Cast type; One of the CAST_ constant definitions.
* Bool values will be treated as plain text flag.
* @param mixed $value The value that failed validation
* @return $this
*/
public function addFieldMessage(
@ -137,7 +157,8 @@ class ValidationResult
$message,
$messageType = ValidationResult::TYPE_ERROR,
$code = null,
$cast = ValidationResult::CAST_TEXT
$cast = ValidationResult::CAST_TEXT,
$value = ValidationResult::VALUE_UNSET,
) {
if ($code && is_numeric($code)) {
throw new InvalidArgumentException("Don't use a numeric code '$code'. Use a string.");
@ -151,7 +172,9 @@ class ValidationResult
'messageType' => $messageType,
'messageCast' => $cast,
];
if ($value !== ValidationResult::VALUE_UNSET) {
$metadata['value'] = $value;
}
if ($code) {
$this->messages[$code] = $metadata;
} else {

View File

@ -119,10 +119,8 @@ class CompositeField extends FormField
* Returns the name (ID) for the element.
* If the CompositeField doesn't have a name, but we still want the ID/name to be set.
* This code generates the ID from the nested children.
*
* @return String $name
*/
public function getName()
public function getName(): string
{
if ($this->name) {
return $this->name;

View File

@ -2,14 +2,17 @@
namespace SilverStripe\Forms;
use SilverStripe\Core\Validation\ConstraintValidator;
use Symfony\Component\Validator\Constraints\Email as EmailConstraint;
use SilverStripe\Validation\EmailValidator;
/**
* Text input field with validation for correct email format according to the relevant RFC.
*/
class EmailField extends TextField
{
private static array $field_validators = [
EmailValidator::class,
];
protected $inputType = 'email';
public function Type()
@ -17,27 +20,6 @@ class EmailField extends TextField
return 'email text';
}
/**
* Validates for RFC compliant email addresses.
*
* @param Validator $validator
*/
public function validate($validator)
{
$this->value = trim($this->value ?? '');
$message = _t('SilverStripe\\Forms\\EmailField.VALIDATION', 'Please enter an email address');
$result = ConstraintValidator::validate(
$this->value,
new EmailConstraint(message: $message, mode: EmailConstraint::VALIDATION_MODE_STRICT),
$this->getName()
);
$validator->getResult()->combineAnd($result);
$isValid = $result->isValid();
return $this->extendValidationResult($isValid, $validator);
}
public function getSchemaValidation()
{
$rules = parent::getSchemaValidation();

View File

@ -106,7 +106,7 @@ class FieldGroup extends CompositeField
* In some cases the FieldGroup doesn't have a title, but we still want
* the ID / name to be set. This code, generates the ID from the nested children
*/
public function getName()
public function getName(): string
{
if ($this->name) {
return $this->name;

View File

@ -15,6 +15,7 @@ use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\View\AttributesHTML;
use SilverStripe\View\SSViewer;
use SilverStripe\Model\ModelData;
use SilverStripe\Validation\FieldValidatorsTrait;
/**
* Represents a field in a form.
@ -44,6 +45,7 @@ class FormField extends RequestHandler
{
use AttributesHTML;
use FormMessage;
use FieldValidatorsTrait;
/** @see $schemaDataType */
const SCHEMA_DATA_TYPE_STRING = 'String';
@ -424,12 +426,10 @@ class FormField extends RequestHandler
/**
* Returns the field name.
*
* @return string
*/
public function getName()
public function getName(): string
{
return $this->name;
return $this->name ?? '';
}
/**
@ -443,12 +443,20 @@ class FormField extends RequestHandler
}
/**
* Returns the field value.
* Alias of getValue()
*
* @see FormField::setSubmittedValue()
* @return mixed
*/
public function Value()
{
return $this->getValue();
}
/**
* Returns the field value.
*/
public function getValue(): mixed
{
return $this->value;
}
@ -1231,15 +1239,21 @@ class FormField extends RequestHandler
}
/**
* Abstract method each {@link FormField} subclass must implement, determines whether the field
* is valid or not based on the value.
* Subclasses can define an existing FieldValidatorClass to validate the FormField value
* They may also override this method to provide custom validation logic
*
* @param Validator $validator
* @return bool
*/
public function validate($validator)
{
return $this->extendValidationResult(true, $validator);
$isValid = true;
$result = $this->validate();
if (!$result->isValid()) {
$isValid = false;
$validator->getResult()->combineAnd($result);
}
return $this->extendValidationResult($isValid, $validator);
}
/**

View File

@ -43,7 +43,7 @@ class SelectionGroup_Item extends CompositeField
return $this;
}
function getValue()
function getValue(): mixed
{
return $this->value;
}

View File

@ -2,6 +2,8 @@
namespace SilverStripe\Forms;
use SilverStripe\Validation\StringValidator;
/**
* Text input field.
*/
@ -14,6 +16,10 @@ class TextField extends FormField implements TippableFieldInterface
protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_TEXT;
private static array $field_validators = [
StringValidator::class => [null, 'getMaxLength'],
];
/**
* @var Tip|null A tip to render beside the input
*/
@ -117,31 +123,6 @@ class TextField extends FormField implements TippableFieldInterface
return $data;
}
/**
* Validate this field
*
* @param Validator $validator
* @return bool
*/
public function validate($validator)
{
$result = true;
if (!is_null($this->maxLength) && mb_strlen($this->value ?? '') > $this->maxLength) {
$name = strip_tags($this->Title() ? $this->Title() : $this->getName());
$validator->validationError(
$this->name,
_t(
'SilverStripe\\Forms\\TextField.VALIDATEMAXLENGTH',
'The value for {name} must not exceed {maxLength} characters in length',
['name' => $name, 'maxLength' => $this->maxLength]
),
"validation"
);
$result = false;
}
return $this->extendValidationResult($result, $validator);
}
public function getSchemaValidation()
{
$rules = parent::getSchemaValidation();

View File

@ -1230,6 +1230,15 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
public function validate()
{
$result = ValidationResult::create();
// Call DBField::validate() on every DBField
$specs = static::getSchema()->fieldSpecs(static::class);
foreach (array_keys($specs) as $fieldName) {
$dbField = $this->dbObject($fieldName);
$validationResult = $dbField->validate();
if (!$validationResult->isValid()) {
$result->combineAnd($validationResult);
}
}
$this->extend('updateValidate', $result);
return $result;
}

View File

@ -8,12 +8,19 @@ use SilverStripe\ORM\DB;
* Represents a signed 8 byte integer field. Do note PHP running as 32-bit might not work with Bigint properly, as it
* would convert the value to a float when queried from the database since the value is a 64-bit one.
*
* @package framework
* @subpackage model
* @see Int
* BigInt is always signed i.e. can be negative
*/
class DBBigInt extends DBInt
{
/**
* The minimum value for a signed 64-bit integer - this is the same as PHP_INT_MIN on 64-bit systems.
*/
public const MIN_VALUE = -9223372036854775808;
/**
* The maximum value for a signed 64-bit integer - this is the same as PHP_INT_MAX on 64-bit systems.
*/
public const MAX_VALUE = 9223372036854775807;
public function requireField(): void
{
@ -28,4 +35,14 @@ class DBBigInt extends DBInt
$values = ['type' => 'bigint', 'parts' => $parts];
DB::require_field($this->tableName, $this->name, $values);
}
public function getMinValue(): int
{
return DBBigInt::MIN_VALUE;
}
public function getMaxValue(): int
{
return DBBigInt::MAX_VALUE;
}
}

View File

@ -12,6 +12,7 @@ use SilverStripe\ORM\DB;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
use SilverStripe\Model\ModelData;
use SilverStripe\Validation\DateValidator;
/**
* Represents a date field.
@ -33,6 +34,7 @@ class DBDate extends DBField
{
/**
* Standard ISO format string for date in CLDR standard format
* This is equivalent to php date format "Y-m-d" e.g. 2024-08-31
*/
public const ISO_DATE = 'y-MM-dd';
@ -42,13 +44,14 @@ class DBDate extends DBField
*/
public const ISO_LOCALE = 'en_US';
private static array $field_validators = [
DateValidator::class,
];
public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static
{
$value = $this->parseDate($value);
if ($value === false) {
throw new InvalidArgumentException(
"Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error."
);
if ($value !== null) {
$value = $this->parseDate($value);
}
$this->value = $value;
return $this;
@ -58,15 +61,10 @@ class DBDate extends DBField
* Parse timestamp or iso8601-ish date into standard iso8601 format
*
* @param mixed $value
* @return string|null|false Formatted date, null if empty but valid, or false if invalid
* @return mixed Formatted date, or the original value if it couldn't be parsed
*/
protected function parseDate(mixed $value): string|null|false
{
// Skip empty values
if (empty($value) && !is_numeric($value)) {
return null;
}
// Determine value to parse
if (is_array($value)) {
$source = $value; // parse array
@ -74,19 +72,18 @@ class DBDate extends DBField
$source = $value; // parse timestamp
} else {
// Convert US date -> iso, fix y2k, etc
$value = $this->fixInputDate($value);
if (is_null($value)) {
return null;
}
$source = strtotime($value ?? ''); // convert string to timestamp
$fixedValue = $this->fixInputDate($value);
// convert string to timestamp
$source = strtotime($fixedValue ?? '');
}
if ($value === false) {
return false;
if (!$source) {
// Unable to parse date, keep as is so that the validator can catch it later
return $value;
}
// Format as iso8601
$formatter = $this->getInternalFormatter();
return $formatter->format($source);
$ret = $formatter->format($source);
return $ret;
}
/**
@ -560,20 +557,12 @@ class DBDate extends DBField
*/
protected function fixInputDate($value)
{
// split
[$year, $month, $day, $time] = $this->explodeDateString($value);
if ((int)$year === 0 && (int)$month === 0 && (int)$day === 0) {
return null;
if (!checkdate((int) $month, (int) $day, (int) $year)) {
// Keep invalid dates as they are so that the validator can catch them later
return $value;
}
// Validate date
if (!checkdate($month ?? 0, $day ?? 0, $year ?? 0)) {
throw new InvalidArgumentException(
"Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error."
);
}
// Convert to y-m-d
// Convert to Y-m-d
return sprintf('%d-%02d-%02d%s', $year, $month, $day, $time);
}
@ -591,11 +580,8 @@ class DBDate extends DBField
$value ?? '',
$matches
)) {
throw new InvalidArgumentException(
"Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error."
);
return [0, 0, 0, ''];
}
$parts = [
$matches['first'],
$matches['second'],
@ -605,11 +591,6 @@ class DBDate extends DBField
if ($parts[0] < 1000 && $parts[2] > 1000) {
$parts = array_reverse($parts ?? []);
}
if ($parts[0] < 1000 && (int)$parts[0] !== 0) {
throw new InvalidArgumentException(
"Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error."
);
}
$parts[] = $matches['time'];
return $parts;
}

View File

@ -13,6 +13,8 @@ use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
use SilverStripe\View\TemplateGlobalProvider;
use SilverStripe\Model\ModelData;
use SilverStripe\Validation\DatetimeValidator;
use SilverStripe\Validation\DateValidator;
/**
* Represents a date-time field.
@ -39,6 +41,7 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider
/**
* Standard ISO format string for date and time in CLDR standard format,
* with a whitespace separating date and time (common database representation, e.g. in MySQL).
* This is equivalent to php date format "Y-m-d H:i:s" e.g. 2024-08-31 09:30:00
*/
public const ISO_DATETIME = 'y-MM-dd HH:mm:ss';
@ -48,10 +51,16 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider
*/
public const ISO_DATETIME_NORMALISED = 'y-MM-dd\'T\'HH:mm:ss';
private static array $field_validators = [
DatetimeValidator::class,
// disable parent validator
DateValidator::class => null,
];
/**
* Flag idicating if this field is considered immutable
* when this is enabled setting the value of this field will return a new field instance
* instead updatin the old one
* instead updating the old one
*/
protected bool $immutable = false;

View File

@ -50,6 +50,16 @@ class DBDecimal extends DBField
return floor($this->value ?? 0.0);
}
public function getWholeSize(): int
{
return $this->wholeSize;
}
public function getDecimalSize(): int
{
return $this->decimalSize;
}
public function requireField(): void
{
$parts = [

View File

@ -0,0 +1,29 @@
<?php
namespace SilverStripe\ORM\FieldType;
use SilverStripe\Forms\EmailField;
use SilverStripe\ORM\FieldType\DBVarchar;
use SilverStripe\Validation\EmailValidator;
use SilverStripe\Forms\FormField;
use SilverStripe\Forms\NullableField;
class DBEmail extends DBVarchar
{
private static array $field_validators = [
EmailValidator::class,
];
public function scaffoldFormField(?string $title = null, array $params = []): ?FormField
{
// Set field with appropriate size
$field = EmailField::create($this->name, $title);
$field->setMaxLength($this->getSize());
// Allow the user to select if it's null instead of automatically assuming empty string is
if (!$this->getNullifyEmpty()) {
return NullableField::create($field);
}
return $field;
}
}

View File

@ -10,6 +10,8 @@ use SilverStripe\Forms\TextField;
use SilverStripe\ORM\Filters\SearchFilter;
use SilverStripe\ORM\Queries\SQLSelect;
use SilverStripe\Model\ModelData;
use SilverStripe\Validation\FieldValidatorsTrait;
use SilverStripe\Validation\FieldValidationInterface;
/**
* Single field in the database.
@ -41,8 +43,9 @@ use SilverStripe\Model\ModelData;
* }
* </code>
*/
abstract class DBField extends ModelData implements DBIndexable
abstract class DBField extends ModelData implements DBIndexable, FieldValidationInterface
{
use FieldValidatorsTrait;
/**
* Raw value of this field
@ -99,6 +102,8 @@ abstract class DBField extends ModelData implements DBIndexable
'ProcessedRAW' => 'HTMLFragment',
];
private static array $field_validators = [];
/**
* Default value in the database.
* Might be overridden on DataObject-level, but still useful for setting defaults on
@ -161,7 +166,7 @@ abstract class DBField extends ModelData implements DBIndexable
*
* If you try an alter the name a warning will be thrown.
*/
public function setName(?string $name): static
public function setName(string $name): static
{
if ($this->name && $this->name !== $name) {
user_error("DBField::setName() shouldn't be called once a DBField already has a name."

View File

@ -8,26 +8,56 @@ use SilverStripe\Model\List\ArrayList;
use SilverStripe\ORM\DB;
use SilverStripe\Model\List\SS_List;
use SilverStripe\Model\ArrayData;
use SilverStripe\Validation\IntValidator;
use SilverStripe\Model\ModelData;
/**
* Represents a signed 32 bit integer field.
*
* Ints are always signed i.e. they can be negative
*/
class DBInt extends DBField
{
/**
* The minimum value for a signed 32-bit integer.
*/
public const MIN_VALUE = -2147483648;
/**
* The maximum value for a signed 32-bit integer.
*/
public const MAX_VALUE = 2147483647;
private static array $field_validators = [
IntValidator::class => ['getMinValue', 'getMaxValue']
];
/**
* Raw value of this field
*
* Set to 0 by default, which is a change of the default value of null for DBField
*/
protected mixed $value = 0;
public function __construct(?string $name = null, int $defaultVal = 0)
{
$this->defaultVal = is_int($defaultVal) ? $defaultVal : 0;
$this->defaultVal = $defaultVal;
parent::__construct($name);
}
/**
* Ensure int values are always returned.
* This is for mis-configured databases that return strings.
*/
public function getValue(): ?int
public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static
{
return (int) $this->value;
if (is_null($value)) {
// Convert null to 0
// Methods such as DataObject::dbObject() can set this to null e.g. when a value has
// not been explicity set on a new record.
$value = 0;
} elseif (is_string($this->value) && preg_match('/^-?\d+$/', $this->value)) {
// Cast int like strings as ints to handle mis-configured databases that return strings
$value = (int) $value;
}
$this->value = $value;
return $this;
}
/**
@ -88,4 +118,20 @@ class DBInt extends DBField
return (int)$value;
}
/**
* Get the minimum int value for this field.
*/
public function getMinValue(): int
{
return DBInt::MIN_VALUE;
}
/**
* Get the maximum int value for this field.
*/
public function getMaxValue(): int
{
return DBInt::MAX_VALUE;
}
}

View File

@ -11,6 +11,7 @@ use SilverStripe\ORM\DB;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
use SilverStripe\Model\ModelData;
use SilverStripe\Validation\TimeValidator;
/**
* Represents a column in the database with the type 'Time'.
@ -26,17 +27,17 @@ class DBTime extends DBField
{
/**
* Standard ISO format string for time in CLDR standard format
* This is equivalent to php date format "H:i:s" e.g. 09:30:00
*/
public const ISO_TIME = 'HH:mm:ss';
private static array $field_validators = [
TimeValidator::class,
];
public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static
{
$value = $this->parseTime($value);
if ($value === false) {
throw new InvalidArgumentException(
'Invalid date passed. Use ' . $this->getISOFormat() . ' to prevent this error.'
);
}
$this->value = $value;
return $this;
}

View File

@ -8,6 +8,7 @@ use SilverStripe\Forms\NullableField;
use SilverStripe\Forms\TextField;
use SilverStripe\ORM\Connect\MySQLDatabase;
use SilverStripe\ORM\DB;
use SilverStripe\Validation\StringValidator;
/**
* Class Varchar represents a variable-length string of up to 255 characters, designed to store raw text
@ -18,6 +19,10 @@ use SilverStripe\ORM\DB;
*/
class DBVarchar extends DBString
{
private static array $field_validators = [
StringValidator::class => [null, 'getSize'],
];
private static array $casting = [
'Initial' => 'Text',
'URL' => 'Text',

View File

@ -0,0 +1,35 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Core\Validation\ConstraintValidator;
use SilverStripe\Validation\StringValidator;
/**
* Abstract class for validators that use Symfony constraints
*/
abstract class AbstractSymfonyValidator extends StringValidator
{
protected function validateValue(): ValidationResult
{
$result = parent::validateValue();
if (!$result->isValid()) {
return $result;
}
$constraintClass = $this->getConstraintClass();
$constraint = new $constraintClass(message: $this->getMessage());
$validationResult = ConstraintValidator::validate($this->value, $constraint, $this->name);
return $result->combineAnd($validationResult);
}
/**
* The symfony constraint class to use
*/
abstract protected function getConstraintClass(): string;
/**
* The message to use when the value is invalid
*/
abstract protected function getMessage(): string;
}

View File

@ -0,0 +1,28 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\FieldValidator;
class BooleanValidator extends FieldValidator
{
private const VALID_VALUES = [
true,
false,
1,
0,
'1',
'0'
];
protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
if (!in_array($this->value, self::VALID_VALUES, true)) {
$message = _t(__CLASS__ . '.INVALID', 'Invalid value');
$result->addFieldError($this->name, $message, value: $this->value);
}
return $result;
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\FieldValidator;
class CompositeValidator extends FieldValidator
{
// TODO FieldList / iterable or similar
private mixed $children;
public function __construct(string $name, mixed $value, mixed $children = null)
{
parent::__construct($name, $value);
$this->children = $children;
}
protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
foreach ($this->children as $child) {
$result->combineAnd($child->validate());
}
return $result;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace SilverStripe\Validation;
use Symfony\Component\Validator\Constraints;
use SilverStripe\Validation\AbstractSymfonyValidator;
/**
* Validates that a value is a valid date, which means that
* - it follows the PHP date format Y-m-d
* - it follows the ISO format y-MM-dd i.e. DBDate::ISO_DATE
*/
class DateValidator extends AbstractSymfonyValidator
{
protected function getConstraintClass(): string
{
return Constraints\Date::class;
}
protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid date');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace SilverStripe\Validation;
use Symfony\Component\Validator\Constraints;
use SilverStripe\Validation\AbstractSymfonyValidator;
/**
* Validates that a value is a valid date, which means that
* - it follows the PHP date format Y-m-d H:i:s
* - it follows the ISO format 'y-MM-dd HH:mm:ss' i.e. DBDateTime::ISO_DATETIME
*/
class DatetimeValidator extends AbstractSymfonyValidator
{
protected function getConstraintClass(): string
{
return Constraints\DateTime::class;
}
protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid date/time');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\NumericValidator;
class DecimalValidator extends NumericValidator
{
/**
* Whole number size e.g. For Decimal(9,2) this would be 9
*/
private int $wholeSize;
/**
* Decimal size e.g. For Decimal(9,2) this would be 2
*/
private int $decimalSize;
public function __construct(string $name, mixed $value, int $wholeSize, int $decimalSize)
{
parent::__construct($name, $value);
$this->wholeSize = $wholeSize;
$this->decimalSize = $decimalSize;
}
protected function validateValue(): ValidationResult
{
$result = parent::validateValue();
if (!$result->isValid()) {
return $result;
}
// Example of how digits are stored in the database
// Decimal(9,2) is allowed a total of 9 digits, and will always round to 2 decimal places
// This means it has a maximum 7 digits before the decimal point
//
// Valid
// 1234567.99
// 9999999.99
// -9999999.99
// 1234567.999 - will round to 1234568.00
//
// Not valid
// 12345678.9 - 8 digits the before the decimal point
// 1234567.891 - 10 digits total
// 9999999.999 - would be rounted to 10000000.00 which exceeds the 9 digits
// Convert to absolute value - any the minus sign is not counted
$absValue = abs($this->value);
// Round to the decimal size which is what the database will do
$rounded = round($absValue, $this->decimalSize);
// Count this number of digits - note the minus 1 for the decimal point
$digitCount = strlen((string) $rounded) - 1;
if ($digitCount > $this->wholeSize) {
$message = _t(__CLASS__ . '.TOOLARGE', 'Number is too large');
$result->addFieldError($this->name, $message, value: $this->value);
}
return $result;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace SilverStripe\Validation;
use Symfony\Component\Validator\Constraints;
use SilverStripe\Validation\AbstractSymfonyValidator;
class EmailValidator extends AbstractSymfonyValidator
{
protected function getConstraintClass(): string
{
return Constraints\Email::class;
}
protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid email address');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\FieldValidator;
class EnumValidator extends FieldValidator
{
private array $allowedValues;
public function __construct(string $name, mixed $value, array $allowedValues)
{
parent::__construct($name, $value);
$this->allowedValues = $allowedValues;
}
protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
if (!in_array($this->value, $this->allowedValues)) {
$message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value');
$result->addFieldError($this->name, $message, value: $this->value);
}
return $result;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Validation\ValidationInterface;
interface FieldValidationInterface extends ValidationInterface
{
public function getName(): string;
public function getValue(): mixed;
}

View File

@ -0,0 +1,39 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\ValidationInterface;
/**
* Abstract class that can be used as a validator for FormFields and DBFields
*/
abstract class FieldValidator implements ValidationInterface
{
protected string $name;
protected mixed $value;
public function __construct(string $name, mixed $value)
{
$this->name = $name;
$this->value = $value;
}
/**
* Validate the value
*/
public function validate(): ValidationResult
{
$result = ValidationResult::create();
$validationResult = $this->validateValue($result);
if (!$validationResult->isValid()) {
$result->combineAnd($validationResult);
}
return $result;
}
/**
* Inner validatation method that that is implemented by subclasses
*/
abstract protected function validateValue(): ValidationResult;
}

View File

@ -0,0 +1,110 @@
<?php
namespace SilverStripe\Validation;
use RuntimeException;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Validation\FieldValidationInterface;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Forms\FormField;
trait FieldValidatorsTrait
{
/**
* FieldValidators configuration for the field, which is either a FormField or DBField
*
* Each item in the array can be one of the following
* a) MyFieldValidator::class,
* b) MyFieldValidator::class => [null, 'getMyArg'],
* c) MyFieldValidator::class => null,
*
* a) Will create a FieldValidator and pass the name and value of the field as args to the constructor
* b) Will create a FieldValidator and pass the name, value, make a pass additional args, calling each
* non-null value on the field e.g. it will skip the first arg and call $field->getMyArg() for the second arg
* c) Will disable a previously set FieldValidator. This is useful to disable a FieldValidator that was set
* on a parent class
*
* You may only have a single instance of a FieldValidator class per field
*/
private static array $field_validators = [];
/**
* Validate this field
*/
public function validate(): ValidationResult
{
$result = ValidationResult::create();
$fieldValidators = $this->getFieldValidators();
foreach ($fieldValidators as $fieldValidator) {
$validationResult = $fieldValidator->validate();
if (!$validationResult->isValid()) {
$result->combineAnd($validationResult);
}
}
return $result;
}
/**
* Get FieldValidators based on `field_validators` configuration
*/
private function getFieldValidators(): array
{
$fieldValidators = [];
// Used to disable a validator that was previously set with an int index
$disabledClasses = [];
$interface = FieldValidationInterface::class;
// temporary check, will make FormField implement FieldValidationInterface in a future PR
$tmp = FormField::class;
if (!is_a($this, $interface) && !is_a($this, $tmp)) {
$class = get_class($this);
throw new RuntimeException("Class $class does not implement interface $interface");
}
/** @var FieldValidationInterface|Configurable $this */
$name = $this->getName();
$value = $this->getValue();
// Field name is required for FieldValidators when called ValidationResult::addFieldMessage()
if ($name === '') {
throw new RuntimeException('Field name is blank');
}
$config = $this->config()->get('field_validators');
foreach ($config as $indexOrClass => $classOrArgCallsOrDisable) {
$class = '';
$argCalls = [];
$disable = false;
if (is_int($indexOrClass)) {
$class = $classOrArgCallsOrDisable;
} else {
$class = $indexOrClass;
$argCalls = $classOrArgCallsOrDisable;
$disable = $classOrArgCallsOrDisable === null;
}
if ($disable) {
$disabledClasses[$class] = true;
continue;
}
if (!is_a($class, FieldValidator::class, true)) {
throw new RuntimeException("Class $class is not a FieldValidator");
}
if (!is_array($argCalls)) {
throw new RuntimeException("argCalls for FieldValidator $class is not an array");
}
$args = [$name, $value];
foreach ($argCalls as $i => $argCall) {
if (!is_string($argCall) && !is_null($argCall)) {
throw new RuntimeException("argCall $i for FieldValidator $class is not a string or null");
}
if ($argCall) {
$args[] = call_user_func([$this, $argCall]);
} else {
$args[] = null;
}
}
$fieldValidators[$class] = Injector::inst()->createWithArgs($class, $args);
}
foreach (array_keys($disabledClasses) as $class) {
unset($fieldValidators[$class]);
}
return array_values($fieldValidators);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\NumericValidator;
class IntValidator extends NumericValidator
{
/**
* Minimum size of the number
*/
private int $minValue;
/**
* Maximum size of the number
*/
private ?int $maxValue;
public function __construct(string $name, mixed $value, int $minValue, int $maxValue)
{
parent::__construct($name, $value);
$this->minValue = $minValue;
$this->maxValue = $maxValue;
}
protected function validateValue(): ValidationResult
{
$result = parent::validateValue();
if (!$result->isValid()) {
return $result;
}
if (!is_int($this->value)) {
$message = _t(__CLASS__ . '.NOTINT', 'Not an integer');
$result->addFieldError($this->name, $message, value: $this->value);
} elseif ($this->value < $this->minValue) {
$message = _t(__CLASS__ . '.TOOSMALL', 'Value is too small');
$result->addFieldError($this->name, $message, value: $this->value);
} elseif ($this->value > $this->maxValue) {
$message = _t(__CLASS__ . '.TOOLARGE', 'Value is too large');
$result->addFieldError($this->name, $message, value: $this->value);
}
return $result;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace SilverStripe\Validation;
use Symfony\Component\Validator\Constraints;
use SilverStripe\Validation\AbstractSymfonyValidator;
class IpValidator extends AbstractSymfonyValidator
{
protected function getConstraintClass(): string
{
return Constraints\Ip::class;
}
protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid IP address');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace SilverStripe\Validation;
use Symfony\Component\Validator\Constraints;
use SilverStripe\Validation\AbstractSymfonyValidator;
/**
* Validates that a value is a valid locale, e.g. de, de_DE)
*/
class LocaleValidator extends AbstractSymfonyValidator
{
protected function getConstraintClass(): string
{
return Constraints\Locale::class;
}
protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid locale');
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\FieldValidator;
class NumericValidator extends FieldValidator
{
protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
if (!is_numeric($this->value)) {
$message = _t(__CLASS__ . '.NOTNUMERIC', 'Must be a number');
$result->addFieldError($this->name, $message, value: $this->value);
return $result;
}
return $result;
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\Validation\FieldValidator;
/**
* Validates that a value is a string and optionally checks its multi-byte length.
*/
class StringValidator extends FieldValidator
{
/**
* The minimum length of the string
*/
private ?int $minLength;
/**
* The maximum length of the string
*/
private ?int $maxLength;
public function __construct(string $name, mixed $value, ?int $minLength = null, ?int $maxLength = null)
{
parent::__construct($name, $value);
$this->minLength = $minLength;
$this->maxLength = $maxLength;
}
protected function validateValue(): ValidationResult
{
$result = ValidationResult::create();
// Allow blank values
if (!$this->value) {
return $result;
}
if (!is_string($this->value)) {
$message = _t(__CLASS__ . '.INVALID', 'Must be a string');
$result->addFieldError($this->name, $message, value: $this->value);
}
$len = mb_strlen($this->value);
if (!is_null($this->minLength) && $len < $this->minLength) {
$message = _t(
__CLASS__ . '.TOOSHORT',
'Must have at least {minLength} characters',
['minLength' => $this->minLength]
);
$result->addFieldError($this->name, $message, value: $this->value);
}
if (!is_null($this->maxLength) && $len > $this->maxLength) {
$message = _t(
__CLASS__ . '.TOOLONG',
'Can not have more than {maxLength} characters',
['maxLength' => $this->maxLength]
);
$result->addFieldError($this->name, $message, value: $this->value);
}
return $result;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace SilverStripe\Validation;
use Symfony\Component\Validator\Constraints;
use SilverStripe\Validation\AbstractSymfonyValidator;
/**
* Validates that a value is a valid date, which means that
* - it follows the PHP date format H:i:s
* - it follows the ISO format 'HH:mm:ss' i.e. DBTime::ISO_TIME
*/
class TimeValidator extends AbstractSymfonyValidator
{
protected function getConstraintClass(): string
{
return Constraints\Time::class;
}
protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid time');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace SilverStripe\Validation;
use Symfony\Component\Validator\Constraints;
use SilverStripe\Validation\AbstractSymfonyValidator;
class UrlValidator extends AbstractSymfonyValidator
{
protected function getConstraintClass(): string
{
return Constraints\Url::class;
}
protected function getMessage(): string
{
return _t(__CLASS__ . '.INVALID', 'Invalid URL');
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace SilverStripe\Validation;
use SilverStripe\Core\Validation\ValidationResult;
interface ValidationInterface
{
public function validate(): ValidationResult;
}