diff --git a/_config/model.yml b/_config/model.yml index 4046feddb..3d8a565e4 100644 --- a/_config/model.yml +++ b/_config/model.yml @@ -2,6 +2,8 @@ Name: corefieldtypes --- SilverStripe\Core\Injector\Injector: + NullableVarchar: + class: SilverStripe\ORM\FieldType\DBNullableVarchar Boolean: class: SilverStripe\ORM\FieldType\DBBoolean Currency: @@ -20,6 +22,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: @@ -36,6 +40,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBHTMLVarchar Int: class: SilverStripe\ORM\FieldType\DBInt + MediumInt: + class: SilverStripe\ORM\FieldType\DBMediumInt BigInt: class: SilverStripe\ORM\FieldType\DBBigInt Locale: diff --git a/src/Forms/EmailField.php b/src/Forms/EmailField.php index 68ea66d41..43879568f 100644 --- a/src/Forms/EmailField.php +++ b/src/Forms/EmailField.php @@ -2,11 +2,18 @@ namespace SilverStripe\Forms; +use SilverStripe\Validation\EmailValidator; + /** - * Text input field with validation for correct email format according to RFC 2822. + * Text input field with validation for correct email format */ class EmailField extends TextField { + private static array $field_validators = [ + [ + 'class' => EmailValidator::class, + ], + ]; protected $inputType = 'email'; /** @@ -17,39 +24,6 @@ class EmailField extends TextField return 'email text'; } - /** - * Validates for RFC 2822 compliant email addresses. - * - * @see http://www.regular-expressions.info/email.html - * @see http://www.ietf.org/rfc/rfc2822.txt - * - * @param Validator $validator - * - * @return string - */ - public function validate($validator) - { - $result = true; - $this->value = trim($this->value ?? ''); - - $pattern = '^[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'; - - // Escape delimiter characters. - $safePattern = str_replace('/', '\\/', $pattern ?? ''); - - if ($this->value && !preg_match('/' . $safePattern . '/i', $this->value ?? '')) { - $validator->validationError( - $this->name, - _t('SilverStripe\\Forms\\EmailField.VALIDATION', 'Please enter an email address'), - 'validation' - ); - - $result = false; - } - - return $this->extendValidationResult($result, $validator); - } - public function getSchemaValidation() { $rules = parent::getSchemaValidation(); diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php index 0d210436b..8b14e1bd7 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -15,6 +15,8 @@ use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\View\AttributesHTML; use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Injector\Injector; +use SilverStripe\Validation\FieldValidator; /** * Represents a field in a form. @@ -275,6 +277,8 @@ class FormField extends RequestHandler 'Description' => 'HTMLFragment', ]; + private static array $field_validators = []; + /** * Structured schema state representing the FormField's current data and validation. * Used to render the FormField as a ReactJS Component on the front-end. @@ -1231,15 +1235,25 @@ 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; + $name = strip_tags($this->Title() ? $this->Title() : $this->getName()); + $fieldValidators = FieldValidator::createFieldValidatorsForField($this, $name, $this->value); + foreach ($fieldValidators as $fieldValidator) { + $validationResult = $fieldValidator->validate(); + if (!$validationResult->isValid()) { + $validator->getResult()->combineAnd($validationResult); + $isValid = false; + } + } + return $this->extendValidationResult($isValid, $validator); } /** diff --git a/src/Forms/TextField.php b/src/Forms/TextField.php index 49aa40b2c..b994a39cc 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,13 @@ class TextField extends FormField implements TippableFieldInterface protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_TEXT; + private static array $field_validators = [ + [ + 'class' => StringValidator::class, + 'argCalls' => [null, 'getMaxLength'], + ] + ]; + /** * @var Tip|null A tip to render beside the input */ @@ -117,31 +126,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/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..fb1e839dd --- /dev/null +++ b/src/ORM/FieldType/DBEmail.php @@ -0,0 +1,31 @@ + 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; + } +} diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php index 38efb5758..1e5869b24 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\Core\Validation\ValidationResult; +use SilverStripe\Validation\FieldValidator; /** * Single field in the database. @@ -43,7 +45,6 @@ use SilverStripe\Model\ModelData; */ abstract class DBField extends ModelData implements DBIndexable { - /** * Raw value of this field */ @@ -99,6 +100,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 @@ -468,6 +471,22 @@ abstract class DBField extends ModelData implements DBIndexable } } + /** + * Validate this field. Called during DataObject::validate(). + */ + public function validate(): ValidationResult + { + $result = ValidationResult::create(); + $fieldValidators = FieldValidator::createFieldValidatorsForField($this, $this->getName(), $this->getValue()); + foreach ($fieldValidators as $fieldValidator) { + $validationResult = $fieldValidator->validate(); + if (!$validationResult->isValid()) { + $result->combineAnd($validationResult); + } + } + return $result; + } + /** * Returns a FormField instance used as a default * for form scaffolding. diff --git a/src/ORM/FieldType/DBInt.php b/src/ORM/FieldType/DBInt.php index 0f46ddf54..9d01b92ff 100644 --- a/src/ORM/FieldType/DBInt.php +++ b/src/ORM/FieldType/DBInt.php @@ -11,6 +11,8 @@ use SilverStripe\Model\ArrayData; /** * Represents a signed 32 bit integer field. + * + * Ints are always signed i.e. they can be negative */ class DBInt extends DBField { diff --git a/src/ORM/FieldType/DBMediumInt.php b/src/ORM/FieldType/DBMediumInt.php new file mode 100644 index 000000000..995455b57 --- /dev/null +++ b/src/ORM/FieldType/DBMediumInt.php @@ -0,0 +1,24 @@ + 'int', + 'precision' => 8, + 'null' => 'not null', + 'default' => $this->defaultVal, + 'arrayValue' => $this->arrayValue + ]; + $values = ['type' => 'int', 'parts' => $parts]; + DB::require_field($this->tableName, $this->name, $values); + } +} diff --git a/src/ORM/FieldType/DBNullableVarchar.php b/src/ORM/FieldType/DBNullableVarchar.php new file mode 100644 index 000000000..106201488 --- /dev/null +++ b/src/ORM/FieldType/DBNullableVarchar.php @@ -0,0 +1,12 @@ + StringValidator::class, + 'argCalls' => [null, 'getSize'], + ] + ]; + private static array $casting = [ 'Initial' => 'Text', 'URL' => 'Text', diff --git a/src/Validation/BooleanValidator.php b/src/Validation/BooleanValidator.php new file mode 100644 index 000000000..1a272c155 --- /dev/null +++ b/src/Validation/BooleanValidator.php @@ -0,0 +1,27 @@ +value, self::VALID_VALUES, true)) { + $message = _t(__CLASS__ . '.INVALID', 'Invalid value'); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Validation/CompositeValidator.php b/src/Validation/CompositeValidator.php new file mode 100644 index 000000000..35e709204 --- /dev/null +++ b/src/Validation/CompositeValidator.php @@ -0,0 +1,26 @@ +children = $children; + } + + protected function validateValue(ValidationResult $result): ValidationResult + { + foreach ($this->children as $child) { + $result->combineAnd($child->validate()); + } + return $result; + } +} diff --git a/src/Validation/DecimalValidator.php b/src/Validation/DecimalValidator.php new file mode 100644 index 000000000..d9f7c7ea3 --- /dev/null +++ b/src/Validation/DecimalValidator.php @@ -0,0 +1,60 @@ +wholeSize = $wholeSize; + $this->decimalSize = $decimalSize; + } + + protected function validateValue(ValidationResult $result): ValidationResult + { + $result = parent::validateValue($result); + 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); + } + return $result; + } +} diff --git a/src/Validation/EmailValidator.php b/src/Validation/EmailValidator.php new file mode 100644 index 000000000..0e787fea2 --- /dev/null +++ b/src/Validation/EmailValidator.php @@ -0,0 +1,26 @@ +isValid()) { + return $result; + } + $message = _t(__CLASS__ . '.INVALID', 'Invalid email address'); + $validationResult = ConstraintValidator::validate( + $this->value, + new Constraints\Email(message: $message), + $this->name + ); + return $result->combineAnd($validationResult); + } +} diff --git a/src/Validation/EnumValidator.php b/src/Validation/EnumValidator.php new file mode 100644 index 000000000..fe76497de --- /dev/null +++ b/src/Validation/EnumValidator.php @@ -0,0 +1,26 @@ +allowedValues = $allowedValues; + } + + protected function validateValue(ValidationResult $result): ValidationResult + { + if (!in_array($this->value, $this->allowedValues)) { + $message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value'); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Validation/FieldValidator.php b/src/Validation/FieldValidator.php new file mode 100644 index 000000000..b8f76e197 --- /dev/null +++ b/src/Validation/FieldValidator.php @@ -0,0 +1,67 @@ +name = $name; + $this->value = $value; + } + + public function validate(): ValidationResult + { + $result = ValidationResult::create(); + $result = $this->validateValue($result); + return $result; + } + + abstract protected function validateValue(ValidationResult $result): ValidationResult; + + public static function createFieldValidatorsForField( + FormField|DBField $field, + string $name, + mixed $value + ): array { + $fieldValidators = []; + $config = $field->config()->get('field_validators'); + foreach ($config as $spec) { + $class = $spec['class']; + $argCalls = $spec['argCalls'] ?? null; + if (!is_a($class, FieldValidator::class, true)) { + throw new RuntimeException("Class $class is not a FieldValidator"); + } + $args = [$name, $value]; + if (!is_null($argCalls)) { + if (!is_array($argCalls)) { + throw new RuntimeException("argCalls for $class is not an array"); + } + foreach ($argCalls as $i => $argCall) { + if (!is_string($argCall) && !is_null($argCall)) { + throw new RuntimeException("argCall $i for $class is not a string or null"); + } + if ($argCall) { + $args[] = call_user_func([$field, $argCall]); + } else { + $args[] = null; + } + } + } + $fieldValidators[] = Injector::inst()->createWithArgs($class, $args); + } + return $fieldValidators; + } +} diff --git a/src/Validation/IntValidator.php b/src/Validation/IntValidator.php new file mode 100644 index 000000000..aab59fbcd --- /dev/null +++ b/src/Validation/IntValidator.php @@ -0,0 +1,42 @@ +minValue = $minValue; + $this->maxValue = $maxValue; + } + + protected function validateValue(ValidationResult $result): ValidationResult + { + $result = parent::validateValue($result); + if (!$result->isValid()) { + return $result; + } + if ($this->value < $this->minValue) { + $message = _t(__CLASS__ . '.TOOSMALL', 'Value is too small'); + $result->addFieldError($this->name, $message); + } elseif ($this->value > $this->maxValue) { + $message = _t(__CLASS__ . '.TOOLARGE', 'Value is too large'); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Validation/NumericValidator.php b/src/Validation/NumericValidator.php new file mode 100644 index 000000000..5bb679b06 --- /dev/null +++ b/src/Validation/NumericValidator.php @@ -0,0 +1,19 @@ +value)) { + $message = _t(__CLASS__ . '.NOTNUMERIC', 'Must be a number'); + $result->addFieldError($this->name, $message); + return $result; + } + return $result; + } +} diff --git a/src/Validation/StringValidator.php b/src/Validation/StringValidator.php new file mode 100644 index 000000000..5c5dc9007 --- /dev/null +++ b/src/Validation/StringValidator.php @@ -0,0 +1,40 @@ +minLength = $minLength; + $this->maxLength = $maxLength; + } + + protected function validateValue(ValidationResult $result): ValidationResult + { + if (!is_null($this->minLength) && mb_strlen($this->value ?? '') < $this->minLength) { + $message = _t( + __CLASS__ . '.TOOSHORT', + 'Must be at least {minLength} characters', + ['minLength' => $this->minLength] + ); + $result->addFieldError($this->name, $message); + } + if (!is_null($this->maxLength) && mb_strlen($this->value ?? '') > $this->maxLength) { + $message = _t( + __CLASS__ . '.TOOLONG', + 'Must have not more than {maxLength} characters', + ['maxLength' => $this->maxLength] + ); + $result->addFieldError($this->name, $message); + } + return $result; + } +}