diff --git a/_config/model.yml b/_config/model.yml index 4046feddb65..8e6840244c6 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/src/Forms/EmailField.php b/src/Forms/EmailField.php index 68ea66d4199..4f670259fb9 100644 --- a/src/Forms/EmailField.php +++ b/src/Forms/EmailField.php @@ -2,11 +2,16 @@ 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 = [ + EmailValidator::class + ]; protected $inputType = 'email'; /** @@ -17,39 +22,6 @@ public function Type() 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 0d210436b0f..8b14e1bd733 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -15,6 +15,8 @@ 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 @@ protected function extendValidationResult(bool $result, Validator $validator): b } /** - * 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 49aa40b2c14..351e7dc6830 100644 --- a/src/Forms/TextField.php +++ b/src/Forms/TextField.php @@ -2,6 +2,8 @@ namespace SilverStripe\Forms; +use SilverStripe\Validation\StringLengthValidator; + /** * 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 = [ + StringLengthValidator::class => [null, 'getMaxLength'] + ]; + /** * @var Tip|null A tip to render beside the input */ @@ -117,31 +123,6 @@ public function getSchemaDataDefaults() 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 36a2ee9e6fa..b43f6e11afd 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -1230,6 +1230,15 @@ public function forceChange() 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/DBEmail.php b/src/ORM/FieldType/DBEmail.php new file mode 100644 index 00000000000..96793f8d539 --- /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 38efb57583f..1e5869b243a 100644 --- a/src/ORM/FieldType/DBField.php +++ b/src/ORM/FieldType/DBField.php @@ -10,6 +10,8 @@ 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 @@ */ 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 @@ public function saveInto(ModelData $model): void } } + /** + * 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/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php index 3081ad34be0..c3b582d7f94 100644 --- a/src/ORM/FieldType/DBVarchar.php +++ b/src/ORM/FieldType/DBVarchar.php @@ -8,6 +8,7 @@ use SilverStripe\Forms\TextField; use SilverStripe\ORM\Connect\MySQLDatabase; use SilverStripe\ORM\DB; +use SilverStripe\Validation\StringLengthValidator; /** * Class Varchar represents a variable-length string of up to 255 characters, designed to store raw text @@ -18,6 +19,10 @@ */ class DBVarchar extends DBString { + private static array $field_validators = [ + StringLengthValidator::class => [null, 'getSize'], + ]; + private static array $casting = [ 'Initial' => 'Text', 'URL' => 'Text', diff --git a/src/Validation/EmailValidator.php b/src/Validation/EmailValidator.php new file mode 100644 index 00000000000..3fd72de8af6 --- /dev/null +++ b/src/Validation/EmailValidator.php @@ -0,0 +1,24 @@ +value, + new Constraints\Email(message: $message), + $this->name + ); + return $result->combineAnd($validationResult); + } +} diff --git a/src/Validation/FieldValidator.php b/src/Validation/FieldValidator.php new file mode 100644 index 00000000000..cb421fa7be7 --- /dev/null +++ b/src/Validation/FieldValidator.php @@ -0,0 +1,72 @@ +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 $classOrIndex => $classOrExtraArgMethods) { + if (is_int($classOrIndex)) { + $class = $classOrExtraArgMethods; + $extraArgMethods = []; + } else { + $class = $classOrIndex; + $extraArgMethods = $classOrExtraArgMethods; + } + if (!is_a($class, FieldValidator::class, true)) { + throw new RuntimeException("Class $class is not a FieldValidator"); + } + if (!is_array($extraArgMethods)) { + throw new RuntimeException("extra arg methods for $class is not an array"); + } + $args = [$name, $value]; + if (!is_null($extraArgMethods)) { + foreach ($extraArgMethods as $i => $extraArgMethod) { + if (!is_string($extraArgMethod) && !is_null($extraArgMethod)) { + throw new RuntimeException("extra arg method $i for $class is not a string or null"); + } + if ($extraArgMethod) { + $args[] = call_user_func([$field, $extraArgMethod]); + } else { + $args[] = null; + } + } + } + $fieldValidators[] = Injector::inst()->createWithArgs($class, $args); + } + return $fieldValidators; + } +} diff --git a/src/Validation/StringLengthValidator.php b/src/Validation/StringLengthValidator.php new file mode 100644 index 00000000000..e5c0bf7c121 --- /dev/null +++ b/src/Validation/StringLengthValidator.php @@ -0,0 +1,33 @@ +minLength = $minLength; + $this->maxLength = $maxLength; + } + + protected function validateValue(ValidationResult $result): ValidationResult + { + if (!is_null($this->maxLength) && mb_strlen($this->value ?? '') > $this->maxLength) { + $message = _t( + 'SilverStripe\\Forms\\TextField.VALIDATEMAXLENGTH', + 'The value for {name} must not exceed {maxLength} characters in length', + ['name' => $this->name, 'maxLength' => $this->maxLength] + ); + $result->addFieldError($this->name, $message); + } + // TODO: minlength check + return $result; + } +}