diff --git a/_config/model.yml b/_config/model.yml index 4046feddb..8e6840244 100644 --- a/_config/model.yml +++ b/_config/model.yml @@ -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: diff --git a/composer.json b/composer.json index f4ac18e47..e51556301 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Core/Validation/ConstraintValidator.php b/src/Core/Validation/ConstraintValidator.php index 9a6568998..941a18a8d 100644 --- a/src/Core/Validation/ConstraintValidator.php +++ b/src/Core/Validation/ConstraintValidator.php @@ -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); } } diff --git a/src/Core/Validation/ValidationResult.php b/src/Core/Validation/ValidationResult.php index a7c7d7443..1c7c34bf3 100644 --- a/src/Core/Validation/ValidationResult.php +++ b/src/Core/Validation/ValidationResult.php @@ -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 { diff --git a/src/Forms/CompositeField.php b/src/Forms/CompositeField.php index 7c1cab23f..57ea04cfb 100644 --- a/src/Forms/CompositeField.php +++ b/src/Forms/CompositeField.php @@ -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; diff --git a/src/Forms/EmailField.php b/src/Forms/EmailField.php index 4426d67f8..78fd4f2cd 100644 --- a/src/Forms/EmailField.php +++ b/src/Forms/EmailField.php @@ -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(); diff --git a/src/Forms/FieldGroup.php b/src/Forms/FieldGroup.php index 9a0d6c675..f7ced1903 100644 --- a/src/Forms/FieldGroup.php +++ b/src/Forms/FieldGroup.php @@ -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; diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php index 0d210436b..f1416b71f 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -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); } /** diff --git a/src/Forms/SelectionGroup_Item.php b/src/Forms/SelectionGroup_Item.php index 55d021859..ab23c0cc8 100644 --- a/src/Forms/SelectionGroup_Item.php +++ b/src/Forms/SelectionGroup_Item.php @@ -43,7 +43,7 @@ class SelectionGroup_Item extends CompositeField return $this; } - function getValue() + function getValue(): mixed { return $this->value; } diff --git a/src/Forms/TextField.php b/src/Forms/TextField.php index 49aa40b2c..d422029e8 100644 --- a/src/Forms/TextField.php +++ b/src/Forms/TextField.php @@ -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(); diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 2b6bed1da..8968d59c3 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -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; } diff --git a/src/ORM/FieldType/DBBigInt.php b/src/ORM/FieldType/DBBigInt.php index c92c2da69..14229c443 100644 --- a/src/ORM/FieldType/DBBigInt.php +++ b/src/ORM/FieldType/DBBigInt.php @@ -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; + } } diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index d8a271d78..b92053299 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -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; } diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 287b64690..bf2d3d553 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -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; diff --git a/src/ORM/FieldType/DBDecimal.php b/src/ORM/FieldType/DBDecimal.php index deab1bcd0..c6c8e44d4 100644 --- a/src/ORM/FieldType/DBDecimal.php +++ b/src/ORM/FieldType/DBDecimal.php @@ -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 = [ diff --git a/src/ORM/FieldType/DBEmail.php b/src/ORM/FieldType/DBEmail.php new file mode 100644 index 000000000..96793f8d5 --- /dev/null +++ b/src/ORM/FieldType/DBEmail.php @@ -0,0 +1,29 @@ +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; + } +} diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php index 38efb5758..b613e2edf 100644 --- a/src/ORM/FieldType/DBField.php +++ b/src/ORM/FieldType/DBField.php @@ -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; * } * */ -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." diff --git a/src/ORM/FieldType/DBInt.php b/src/ORM/FieldType/DBInt.php index 0f46ddf54..bfe2c5ccf 100644 --- a/src/ORM/FieldType/DBInt.php +++ b/src/ORM/FieldType/DBInt.php @@ -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; + } } diff --git a/src/ORM/FieldType/DBTime.php b/src/ORM/FieldType/DBTime.php index fac285be1..f1c256ebb 100644 --- a/src/ORM/FieldType/DBTime.php +++ b/src/ORM/FieldType/DBTime.php @@ -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; } diff --git a/src/ORM/FieldType/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php index 3081ad34b..ee7058de3 100644 --- a/src/ORM/FieldType/DBVarchar.php +++ b/src/ORM/FieldType/DBVarchar.php @@ -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', diff --git a/src/Validation/AbstractSymfonyValidator.php b/src/Validation/AbstractSymfonyValidator.php new file mode 100644 index 000000000..1cbba47ac --- /dev/null +++ b/src/Validation/AbstractSymfonyValidator.php @@ -0,0 +1,35 @@ +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; +} diff --git a/src/Validation/BooleanValidator.php b/src/Validation/BooleanValidator.php new file mode 100644 index 000000000..ce18a334a --- /dev/null +++ b/src/Validation/BooleanValidator.php @@ -0,0 +1,28 @@ +value, self::VALID_VALUES, true)) { + $message = _t(__CLASS__ . '.INVALID', 'Invalid value'); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Validation/CompositeValidator.php b/src/Validation/CompositeValidator.php new file mode 100644 index 000000000..e3cc5d4cb --- /dev/null +++ b/src/Validation/CompositeValidator.php @@ -0,0 +1,27 @@ +children = $children; + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + foreach ($this->children as $child) { + $result->combineAnd($child->validate()); + } + return $result; + } +} diff --git a/src/Validation/DateValidator.php b/src/Validation/DateValidator.php new file mode 100644 index 000000000..1723a4f47 --- /dev/null +++ b/src/Validation/DateValidator.php @@ -0,0 +1,24 @@ +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; + } +} diff --git a/src/Validation/EmailValidator.php b/src/Validation/EmailValidator.php new file mode 100644 index 000000000..6221e40c9 --- /dev/null +++ b/src/Validation/EmailValidator.php @@ -0,0 +1,19 @@ +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; + } +} diff --git a/src/Validation/FieldValidationInterface.php b/src/Validation/FieldValidationInterface.php new file mode 100644 index 000000000..79464de05 --- /dev/null +++ b/src/Validation/FieldValidationInterface.php @@ -0,0 +1,12 @@ +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; +} diff --git a/src/Validation/FieldValidatorsTrait.php b/src/Validation/FieldValidatorsTrait.php new file mode 100644 index 000000000..2637a3bf6 --- /dev/null +++ b/src/Validation/FieldValidatorsTrait.php @@ -0,0 +1,110 @@ + [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); + } +} diff --git a/src/Validation/IntValidator.php b/src/Validation/IntValidator.php new file mode 100644 index 000000000..3d5a0b97c --- /dev/null +++ b/src/Validation/IntValidator.php @@ -0,0 +1,45 @@ +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; + } +} diff --git a/src/Validation/IpValidator.php b/src/Validation/IpValidator.php new file mode 100644 index 000000000..37a7c535a --- /dev/null +++ b/src/Validation/IpValidator.php @@ -0,0 +1,19 @@ +value)) { + $message = _t(__CLASS__ . '.NOTNUMERIC', 'Must be a number'); + $result->addFieldError($this->name, $message, value: $this->value); + return $result; + } + return $result; + } +} diff --git a/src/Validation/StringValidator.php b/src/Validation/StringValidator.php new file mode 100644 index 000000000..991cf43ef --- /dev/null +++ b/src/Validation/StringValidator.php @@ -0,0 +1,60 @@ +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; + } +} diff --git a/src/Validation/TimeValidator.php b/src/Validation/TimeValidator.php new file mode 100644 index 000000000..074cfbead --- /dev/null +++ b/src/Validation/TimeValidator.php @@ -0,0 +1,24 @@ +