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/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..21ad87e6c 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,13 @@ class TextField extends FormField implements TippableFieldInterface protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_TEXT; + private static array $field_validators = [ + [ + 'class' => StringLengthValidator::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 36a2ee9e6..b43f6e11a 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/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/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php index 3081ad34b..009282fa4 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\StringLengthValidator; /** * Class Varchar represents a variable-length string of up to 255 characters, designed to store raw text @@ -18,6 +19,13 @@ use SilverStripe\ORM\DB; */ class DBVarchar extends DBString { + private static array $field_validators = [ + [ + 'class' => StringLengthValidator::class, + 'argCalls' => [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 000000000..3fd72de8a --- /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 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/StringLengthValidator.php b/src/Validation/StringLengthValidator.php new file mode 100644 index 000000000..e5c0bf7c1 --- /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; + } +}