From a213fe2a08a8d162ec3a7b4bff3f655cef8b1796 Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Fri, 11 Oct 2024 10:40:42 +1300 Subject: [PATCH] NEW Validate DBFields --- _config/model.yml | 6 + composer.json | 1 + src/Core/Validation/ConstraintValidator.php | 4 +- .../AbstractSymfonyFieldValidator.php | 48 +++++ .../FieldValidation/BigIntFieldValidator.php | 36 ++++ .../BooleanIntFieldValidator.php | 23 +++ .../CompositeFieldValidator.php | 33 ++++ .../FieldValidation/DateFieldValidator.php | 40 ++++ .../DatetimeFieldValidator.php | 23 +++ .../FieldValidation/DecimalFieldValidator.php | 65 +++++++ .../FieldValidation/EmailFieldValidator.php | 19 ++ .../FieldValidation/EnumFieldValidator.php | 27 +++ .../FieldValidationInterface.php | 12 ++ .../FieldValidation/FieldValidator.php | 39 ++++ .../FieldValidation/FieldValidatorsTrait.php | 118 ++++++++++++ .../FieldValidation/IntFieldValidator.php | 46 +++++ .../FieldValidation/IpFieldValidator.php | 30 +++ .../FieldValidation/LocaleFieldValidator.php | 22 +++ .../MultiEnumFieldValidator.php | 31 +++ .../FieldValidation/NumericFieldValidator.php | 56 ++++++ .../FieldValidation/StringFieldValidator.php | 69 +++++++ .../FieldValidation/TimeFieldValidator.php | 23 +++ .../FieldValidation/UrlFieldValidator.php | 19 ++ src/Core/Validation/ValidationInterface.php | 10 + src/Core/Validation/ValidationResult.php | 43 ++++- src/Forms/CompositeField.php | 4 +- src/Forms/EmailField.php | 28 +-- src/Forms/FieldGroup.php | 2 +- src/Forms/FormField.php | 45 ++++- src/Forms/SelectionGroup_Item.php | 2 +- src/Forms/TextField.php | 39 ++-- src/ORM/DataObject.php | 12 ++ src/ORM/FieldType/DBBigInt.php | 13 +- src/ORM/FieldType/DBBoolean.php | 32 +++- src/ORM/FieldType/DBClassNameVarchar.php | 5 + src/ORM/FieldType/DBComposite.php | 14 ++ src/ORM/FieldType/DBDate.php | 63 +++---- src/ORM/FieldType/DBDatetime.php | 11 +- src/ORM/FieldType/DBDecimal.php | 19 +- src/ORM/FieldType/DBEmail.php | 29 +++ src/ORM/FieldType/DBEnum.php | 19 +- src/ORM/FieldType/DBField.php | 29 ++- src/ORM/FieldType/DBFloat.php | 4 +- src/ORM/FieldType/DBForeignKey.php | 5 + src/ORM/FieldType/DBInt.php | 34 +++- src/ORM/FieldType/DBIp.php | 13 ++ src/ORM/FieldType/DBLocale.php | 5 + src/ORM/FieldType/DBMultiEnum.php | 18 ++ src/ORM/FieldType/DBPolymorphicForeignKey.php | 6 + src/ORM/FieldType/DBString.php | 20 +- src/ORM/FieldType/DBTime.php | 11 +- src/ORM/FieldType/DBUrl.php | 22 +++ src/ORM/FieldType/DBVarchar.php | 5 + src/ORM/FieldType/DBYear.php | 64 ++++++- .../BigIntFieldValidatorTest.php | 75 ++++++++ .../BooleanIntFieldValidatorTest.php | 72 +++++++ .../CompositeFieldValidatorTest.php | 97 ++++++++++ .../DateFieldValidatorTest.php | 48 +++++ .../DatetimeFieldValidatorTest.php | 52 +++++ .../DecimalFieldValidatorTest.php | 138 ++++++++++++++ .../EmailFieldValidatorTest.php | 37 ++++ .../EnumFieldValidatorTest.php | 54 ++++++ .../FieldValidation/IntFieldValidatorTest.php | 76 ++++++++ .../FieldValidation/IpFieldValidatorTest.php | 45 +++++ .../LocaleFieldValidatorTest.php | 61 ++++++ .../MultiEnumFieldValidatorTest.php | 84 +++++++++ .../NumericFieldValidatorTest.php | 80 ++++++++ .../StringFieldValidatorTest.php | 149 +++++++++++++++ .../TimeFieldValidatorTest.php | 48 +++++ .../FieldValidation/UrlFieldValidatorTest.php | 45 +++++ tests/php/Forms/TextFieldTest.php | 39 ++++ tests/php/ORM/DBCompositeTest.php | 11 ++ tests/php/ORM/DBEnumTest.php | 34 ++++ tests/php/ORM/DBFieldTest.php | 178 ++++++++++++++++++ tests/php/ORM/DBForiegnKeyTest.php | 44 +++++ tests/php/ORM/DBIntTest.php | 75 +++++++- tests/php/ORM/DBMultiEnumTest.php | 44 +++++ tests/php/ORM/DBStringTest.php | 27 +++ tests/php/ORM/DBYearTest.php | 103 +++++++++- 79 files changed, 2968 insertions(+), 164 deletions(-) create mode 100644 src/Core/Validation/FieldValidation/AbstractSymfonyFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/BigIntFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/BooleanIntFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/CompositeFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/DateFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/DatetimeFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/DecimalFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/EmailFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/EnumFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidationInterface.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidatorsTrait.php create mode 100644 src/Core/Validation/FieldValidation/IntFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/IpFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/LocaleFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/MultiEnumFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/NumericFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/StringFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/TimeFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/UrlFieldValidator.php create mode 100644 src/Core/Validation/ValidationInterface.php create mode 100644 src/ORM/FieldType/DBEmail.php create mode 100644 src/ORM/FieldType/DBIp.php create mode 100644 src/ORM/FieldType/DBUrl.php create mode 100644 tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/BooleanIntFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/CompositeFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/DateFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/DecimalFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/EnumFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php create mode 100644 tests/php/ORM/DBForiegnKeyTest.php create mode 100644 tests/php/ORM/DBMultiEnumTest.php diff --git a/_config/model.yml b/_config/model.yml index 4046feddb..ca4d8e193 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: @@ -36,6 +38,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBHTMLVarchar Int: class: SilverStripe\ORM\FieldType\DBInt + IP: + class: SilverStripe\ORM\FieldType\DBIp BigInt: class: SilverStripe\ORM\FieldType\DBBigInt Locale: @@ -58,6 +62,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBText Time: class: SilverStripe\ORM\FieldType\DBTime + URL: + class: SilverStripe\ORM\FieldType\DBUrl Varchar: class: SilverStripe\ORM\FieldType\DBVarchar Year: 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/FieldValidation/AbstractSymfonyFieldValidator.php b/src/Core/Validation/FieldValidation/AbstractSymfonyFieldValidator.php new file mode 100644 index 000000000..195bd0eb5 --- /dev/null +++ b/src/Core/Validation/FieldValidation/AbstractSymfonyFieldValidator.php @@ -0,0 +1,48 @@ +isValid()) { + return $result; + } + $constraintClass = $this->getConstraintClass(); + $args = [ + ...$this->getContraintNamedArgs(), + 'message' => $this->getMessage(), + ]; + $constraint = new $constraintClass(...$args); + $validationResult = ConstraintValidator::validate($this->value, $constraint, $this->name); + return $result->combineAnd($validationResult); + } + + /** + * The symfony constraint class to use + */ + abstract protected function getConstraintClass(): string; + + /** + * The named args to pass to the constraint + * Defined named args as assoc array keys + */ + protected function getContraintNamedArgs(): array + { + return []; + } + + /** + * The message to use when the value is invalid + */ + abstract protected function getMessage(): string; +} diff --git a/src/Core/Validation/FieldValidation/BigIntFieldValidator.php b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php new file mode 100644 index 000000000..7a77b0daf --- /dev/null +++ b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php @@ -0,0 +1,36 @@ +value !== 1 && $this->value !== 0) { + $message = _t(__CLASS__ . '.INVALID', 'Invalid value'); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/CompositeFieldValidator.php b/src/Core/Validation/FieldValidation/CompositeFieldValidator.php new file mode 100644 index 000000000..60a83288c --- /dev/null +++ b/src/Core/Validation/FieldValidation/CompositeFieldValidator.php @@ -0,0 +1,33 @@ +value as $child) { + $result->combineAnd($child->validate()); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/DateFieldValidator.php b/src/Core/Validation/FieldValidation/DateFieldValidator.php new file mode 100644 index 000000000..4b98bc68b --- /dev/null +++ b/src/Core/Validation/FieldValidation/DateFieldValidator.php @@ -0,0 +1,40 @@ +value) { + return $result; + } + // Not using symfony/validator because it was allowing d-m-Y format strings + $date = date_parse_from_format($this->getFormat(), $this->value ?? ''); + if ($date === false || $date['error_count'] > 0 || $date['warning_count'] > 0) { + $result->addFieldError($this->name, $this->getMessage()); + } + return $result; + } + + protected function getFormat(): string + { + return 'Y-m-d'; + } + + protected function getMessage(): string + { + return _t(__CLASS__ . '.INVALID', 'Invalid date'); + } +} diff --git a/src/Core/Validation/FieldValidation/DatetimeFieldValidator.php b/src/Core/Validation/FieldValidation/DatetimeFieldValidator.php new file mode 100644 index 000000000..855f8fb56 --- /dev/null +++ b/src/Core/Validation/FieldValidation/DatetimeFieldValidator.php @@ -0,0 +1,23 @@ +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(5,2) is allowed a total of 5 digits, and will always round to 2 decimal places + // This means it has a maximum 3 digits before the decimal point + // + // Valid + // 123.99 + // 999.99 + // -999.99 + // 123.999 - will round to 124.00 + // + // Not valid + // 1234.9 - 4 digits the before the decimal point + // 999.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); + // Get formatted as a string, which will right pad with zeros to the decimal size + $rounded = number_format($rounded, $this->decimalSize, thousands_separator: ''); + // Count this number of digits - the minus 1 is for the decimal point + $digitCount = strlen((string) $rounded) - 1; + if ($digitCount > $this->wholeSize) { + $message = _t( + __CLASS__ . '.TOOLARGE', + 'Digit count cannot be greater than than {wholeSize}', + ['wholeSize' => $this->wholeSize] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/EmailFieldValidator.php b/src/Core/Validation/FieldValidation/EmailFieldValidator.php new file mode 100644 index 000000000..acd9ee737 --- /dev/null +++ b/src/Core/Validation/FieldValidation/EmailFieldValidator.php @@ -0,0 +1,19 @@ +allowedValues = $allowedValues; + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + if (!in_array($this->value, $this->allowedValues, true)) { + $message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value'); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/FieldValidationInterface.php b/src/Core/Validation/FieldValidation/FieldValidationInterface.php new file mode 100644 index 000000000..ae4826ba2 --- /dev/null +++ b/src/Core/Validation/FieldValidation/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/Core/Validation/FieldValidation/FieldValidatorsTrait.php b/src/Core/Validation/FieldValidation/FieldValidatorsTrait.php new file mode 100644 index 000000000..d3d7c3db9 --- /dev/null +++ b/src/Core/Validation/FieldValidation/FieldValidatorsTrait.php @@ -0,0 +1,118 @@ + [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->getValueForValidation(); + // Field name is required for FieldValidators when called ValidationResult::addFieldMessage() + if ($name === '') { + throw new RuntimeException('Field name is blank'); + } + $classes = []; + $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; + } else { + if (isset($disabledClasses[$class])) { + unset($disabledClasses[$class]); + } + } + 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"); + } + $classes[$class] = $argCalls; + } + foreach (array_keys($disabledClasses) as $class) { + unset($classes[$class]); + } + foreach ($classes as $class => $argCalls) { + $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); + } + return array_values($fieldValidators); + } +} diff --git a/src/Core/Validation/FieldValidation/IntFieldValidator.php b/src/Core/Validation/FieldValidation/IntFieldValidator.php new file mode 100644 index 000000000..713d1b45f --- /dev/null +++ b/src/Core/Validation/FieldValidation/IntFieldValidator.php @@ -0,0 +1,46 @@ +value)) { + $message = _t(__CLASS__ . '.WRONGTYPE', 'Must be an integer'); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/IpFieldValidator.php b/src/Core/Validation/FieldValidation/IpFieldValidator.php new file mode 100644 index 000000000..789eb8fcf --- /dev/null +++ b/src/Core/Validation/FieldValidation/IpFieldValidator.php @@ -0,0 +1,30 @@ + Constraints\Ip::ALL, + ]; + } + + protected function getMessage(): string + { + return _t(__CLASS__ . '.INVALID', 'Invalid IP address'); + } +} diff --git a/src/Core/Validation/FieldValidation/LocaleFieldValidator.php b/src/Core/Validation/FieldValidation/LocaleFieldValidator.php new file mode 100644 index 000000000..accdf8a7c --- /dev/null +++ b/src/Core/Validation/FieldValidation/LocaleFieldValidator.php @@ -0,0 +1,22 @@ +value as $value) { + if (!in_array($value, $this->allowedValues, true)) { + $message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value'); + $result->addFieldError($this->name, $message, value: $value); + break; + } + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/NumericFieldValidator.php b/src/Core/Validation/FieldValidation/NumericFieldValidator.php new file mode 100644 index 000000000..8b2747471 --- /dev/null +++ b/src/Core/Validation/FieldValidation/NumericFieldValidator.php @@ -0,0 +1,56 @@ +minValue = $minValue; + $this->maxValue = $maxValue; + parent::__construct($name, $value); + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + if (!is_numeric($this->value) || is_string($this->value)) { + // Must be a numeric value, though not as a numeric string + $message = _t(__CLASS__ . '.WRONGTYPE', 'Must be numeric'); + $result->addFieldError($this->name, $message, value: $this->value); + return $result; + } elseif (isset($this->minValue) && $this->value < $this->minValue) { + $message = _t( + __CLASS__ . '.TOOSMALL', + 'Value cannot be less than {minValue}', + ['minValue' => $this->minValue] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } elseif (isset($this->maxValue) && $this->value > $this->maxValue) { + $message = _t( + __CLASS__ . '.TOOLARGE', + 'Value cannot be greater than {maxValue}', + ['maxValue' => $this->maxValue] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/StringFieldValidator.php b/src/Core/Validation/FieldValidation/StringFieldValidator.php new file mode 100644 index 000000000..f1e7238e5 --- /dev/null +++ b/src/Core/Validation/FieldValidation/StringFieldValidator.php @@ -0,0 +1,69 @@ +minLength = $minLength; + $this->maxLength = $maxLength; + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + if (!is_string($this->value)) { + $message = _t(__CLASS__ . '.WRONGTYPE', 'Must be a string'); + $result->addFieldError($this->name, $message, value: $this->value); + return $result; + } + // Blank strings are valid, even if there's a minLength requirement + if ($this->value === '') { + return $result; + } + $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/Core/Validation/FieldValidation/TimeFieldValidator.php b/src/Core/Validation/FieldValidation/TimeFieldValidator.php new file mode 100644 index 000000000..f2a1c8d75 --- /dev/null +++ b/src/Core/Validation/FieldValidation/TimeFieldValidator.php @@ -0,0 +1,23 @@ +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..752f04775 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\Core\Validation\FieldValidation\EmailFieldValidator; /** * Text input field with validation for correct email format according to the relevant RFC. */ class EmailField extends TextField { + private static array $field_validators = [ + EmailFieldValidator::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..f299bd2e8 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\Core\Validation\FieldValidation\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,16 +443,32 @@ 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; } + /** + * Get the value of this field for field validation + */ + public function getValueForValidation(): mixed + { + return $this->getValue(); + } + /** * Method to save this form field into the given record. * @@ -1231,15 +1247,28 @@ 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 = ValidationResult::create(); + $fieldValidators = $this->getFieldValidators(); + foreach ($fieldValidators as $fieldValidator) { + $validationResult = $fieldValidator->validate(); + if (!$validationResult->isValid()) { + $result->combineAnd($validationResult); + } + } + 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..e89782f26 100644 --- a/src/Forms/TextField.php +++ b/src/Forms/TextField.php @@ -2,6 +2,8 @@ namespace SilverStripe\Forms; +use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator; + /** * 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 = [ + StringFieldValidator::class => [null, 'getMaxLength'], + ]; + /** * @var Tip|null A tip to render beside the input */ @@ -43,6 +49,14 @@ class TextField extends FormField implements TippableFieldInterface parent::__construct($name, $title, $value); } + public function setValue($value, $data = null) + { + parent::setValue($value, $data = null); + if (is_null($this->value)) { + $this->value = ''; + } + } + /** * @param int $maxLength * @return $this @@ -117,31 +131,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..160ab50a5 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; } @@ -3268,6 +3277,9 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro /** @var DBField $obj */ $table = $schema->tableName($class); $obj = Injector::inst()->create($spec, $fieldName); + if (is_null($value)) { + $value = $obj->getDefaultValue(); + } $obj->setTable($table); $obj->setValue($value, $this, false); return $obj; diff --git a/src/ORM/FieldType/DBBigInt.php b/src/ORM/FieldType/DBBigInt.php index c92c2da69..9abb70c55 100644 --- a/src/ORM/FieldType/DBBigInt.php +++ b/src/ORM/FieldType/DBBigInt.php @@ -2,18 +2,24 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\BigIntFieldValidator; 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 + * Their range is -9223372036854775808 to 9223372036854775807 */ class DBBigInt extends DBInt { + private static array $field_validators = [ + // Remove parent validator and add BigIntValidator instead + IntFieldValidator::class => null, + BigIntFieldValidator::class, + ]; public function requireField(): void { @@ -24,7 +30,6 @@ class DBBigInt extends DBInt 'default' => $this->defaultVal, 'arrayValue' => $this->arrayValue ]; - $values = ['type' => 'bigint', 'parts' => $parts]; DB::require_field($this->tableName, $this->name, $values); } diff --git a/src/ORM/FieldType/DBBoolean.php b/src/ORM/FieldType/DBBoolean.php index c4d4c1c48..bdb2954dc 100644 --- a/src/ORM/FieldType/DBBoolean.php +++ b/src/ORM/FieldType/DBBoolean.php @@ -2,6 +2,7 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Core\Validation\FieldValidation\BooleanIntFieldValidator; use SilverStripe\Forms\CheckboxField; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; @@ -9,13 +10,18 @@ use SilverStripe\ORM\DB; use SilverStripe\Model\ModelData; /** - * Represents a boolean field. + * Represents a boolean field + * Values are stored as a tinyint i.e. 1 or 0 and NOT as true or false */ class DBBoolean extends DBField { + private static array $field_validators = [ + BooleanIntFieldValidator::class, + ]; + public function __construct(?string $name = null, bool|int $defaultVal = 0) { - $this->defaultVal = ($defaultVal) ? 1 : 0; + $this->setDefaultValue($defaultVal ? 1 : 0); parent::__construct($name); } @@ -34,6 +40,13 @@ class DBBoolean extends DBField DB::require_field($this->tableName, $this->name, $values); } + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value); + $this->value = $this->convertBooleanLikeValueToTinyInt($value); + return $this; + } + public function Nice(): string { return ($this->value) ? _t(__CLASS__ . '.YESANSWER', 'Yes') : _t(__CLASS__ . '.NOANSWER', 'No'); @@ -77,12 +90,19 @@ class DBBoolean extends DBField ->setEmptyString($anyText); } - public function nullValue(): ?int + public function nullValue(): int { return 0; } public function prepValueForDB(mixed $value): array|int|null + { + $ret = $this->convertBooleanLikeValueToTinyInt($value); + // Ensure a tiny int is returned no matter what e.g. value is an + return $ret ? 1 : 0; + } + + private function convertBooleanLikeValueToTinyInt(mixed $value): mixed { if (is_bool($value)) { return $value ? 1 : 0; @@ -94,12 +114,16 @@ class DBBoolean extends DBField switch (strtolower($value ?? '')) { case 'false': case 'f': + case '0': return 0; case 'true': case 't': + case '1': return 1; } } - return $value ? 1 : 0; + // Note that something like "lorem" will NOT be converted to 1 + // instead it will throw a ValidationException in BooleanIntFieldValidator + return $value; } } diff --git a/src/ORM/FieldType/DBClassNameVarchar.php b/src/ORM/FieldType/DBClassNameVarchar.php index 2959caba7..9b04f701a 100644 --- a/src/ORM/FieldType/DBClassNameVarchar.php +++ b/src/ORM/FieldType/DBClassNameVarchar.php @@ -3,6 +3,7 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\ORM\FieldType\DBVarchar; +use SilverStripe\Core\Validation\FieldValidation\EnumFieldValidator; /** * An alternative to DBClassName that stores the class name as a varchar instead of an enum @@ -24,4 +25,8 @@ use SilverStripe\ORM\FieldType\DBVarchar; class DBClassNameVarchar extends DBVarchar { use DBClassNameTrait; + + private static array $field_validators = [ + EnumFieldValidator::class => ['getEnum'], + ]; } diff --git a/src/ORM/FieldType/DBComposite.php b/src/ORM/FieldType/DBComposite.php index 7060417ea..ed9a1ea93 100644 --- a/src/ORM/FieldType/DBComposite.php +++ b/src/ORM/FieldType/DBComposite.php @@ -8,6 +8,7 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\ORM\Queries\SQLSelect; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\CompositeFieldValidator; /** * Extend this class when designing a {@link DBField} that doesn't have a 1-1 mapping with a database field. @@ -25,6 +26,10 @@ use SilverStripe\Model\ModelData; */ abstract class DBComposite extends DBField { + private static array $field_validators = [ + CompositeFieldValidator::class, + ]; + /** * Similar to {@link DataObject::$db}, * holds an array of composite field names. @@ -190,6 +195,15 @@ abstract class DBComposite extends DBField return $this; } + public function getValueForValidation(): mixed + { + $fields = []; + foreach (array_keys($this->compositeDatabaseFields()) as $fieldName) { + $fields[] = $this->dbObject($fieldName); + } + return $fields; + } + /** * Bind this field to the model, and set the underlying table to that of the owner */ diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index d8a271d78..23d1acbcb 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\Core\Validation\FieldValidation\DateFieldValidator; /** * 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 = [ + DateFieldValidator::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..4ae0f373b 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\Core\Validation\FieldValidation\DatetimeFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator; /** * 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 = [ + DatetimeFieldValidator::class, + // disable parent validator + DateFieldValidator::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..206fbbe82 100644 --- a/src/ORM/FieldType/DBDecimal.php +++ b/src/ORM/FieldType/DBDecimal.php @@ -2,6 +2,7 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Core\Validation\FieldValidation\DecimalFieldValidator; use SilverStripe\Forms\FormField; use SilverStripe\Forms\NumericField; use SilverStripe\ORM\DB; @@ -12,6 +13,10 @@ use SilverStripe\Model\ModelData; */ class DBDecimal extends DBField { + private static array $field_validators = [ + DecimalFieldValidator::class => ['getWholeSize', 'getDecimalSize'], + ]; + /** * Whole number size */ @@ -35,7 +40,7 @@ class DBDecimal extends DBField $this->wholeSize = is_int($wholeSize) ? $wholeSize : 9; $this->decimalSize = is_int($decimalSize) ? $decimalSize : 2; - $this->defaultValue = number_format((float) $defaultValue, $this->decimalSize); + $this->setDefaultValue(round($defaultValue, $this->decimalSize)); parent::__construct($name); } @@ -50,6 +55,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 = [ @@ -91,7 +106,7 @@ class DBDecimal extends DBField ->setScale($this->decimalSize); } - public function nullValue(): ?int + public function nullValue(): int { return 0; } diff --git a/src/ORM/FieldType/DBEmail.php b/src/ORM/FieldType/DBEmail.php new file mode 100644 index 000000000..12eb2f5b6 --- /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/DBEnum.php b/src/ORM/FieldType/DBEnum.php index e71e6d17d..dab473f6f 100644 --- a/src/ORM/FieldType/DBEnum.php +++ b/src/ORM/FieldType/DBEnum.php @@ -3,12 +3,14 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\Core\Config\Config; +use SilverStripe\Core\Validation\FieldValidation\EnumFieldValidator; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; use SilverStripe\Forms\SelectField; use SilverStripe\Core\ArrayLib; use SilverStripe\ORM\Connect\MySQLDatabase; use SilverStripe\ORM\DB; +use SilverStripe\Model\ModelData; /** * Class Enum represents an enumeration of a set of strings. @@ -17,6 +19,10 @@ use SilverStripe\ORM\DB; */ class DBEnum extends DBString { + private static array $field_validators = [ + EnumFieldValidator::class => ['getEnum'], + ]; + /** * List of enum values */ @@ -73,14 +79,14 @@ class DBEnum extends DBString // If there's a default, then use this if ($default && !is_int($default)) { - if (in_array($default, $enum ?? [])) { + if (in_array($default, $enum)) { $this->setDefault($default); } else { throw new \InvalidArgumentException( "Enum::__construct() The default value '$default' does not match any item in the enumeration" ); } - } elseif (is_int($default) && $default < count($enum ?? [])) { + } elseif (is_int($default) && $default < count($enum)) { // Set to specified index if given $this->setDefault($enum[$default]); } else { @@ -242,4 +248,13 @@ class DBEnum extends DBString $this->setDefaultValue($default); return $this; } + + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value, $record, $markChanged); + if (empty($this->value)) { + $this->value = $this->getDefault(); + } + return $this; + } } diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php index 38efb5758..6b4348ad6 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\FieldValidation\FieldValidatorsTrait; +use SilverStripe\Core\Validation\FieldValidation\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,12 +102,14 @@ 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 * already existing records after a db-build. */ - protected mixed $defaultVal = null; + private mixed $defaultValue = null; /** * Provide the DBField name and an array of options, e.g. ['index' => true], or ['nullifyEmpty' => false] @@ -121,6 +126,8 @@ abstract class DBField extends ModelData implements DBIndexable } $this->setOptions($options); } + // Setting value needs to happen below the call to setOptions() in case the default value is set there + $this->value = $this->getDefaultValue(); parent::__construct(); } @@ -161,7 +168,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." @@ -189,6 +196,18 @@ abstract class DBField extends ModelData implements DBIndexable return $this->value; } + /** + * Get the value of this field for field validation + */ + public function getValueForValidation(): mixed + { + $value = $this->getValue(); + if (is_null($value)) { + return $this->nullValue(); + } + return $value; + } + /** * Set the value of this field in various formats. * Used by {@link DataObject->getField()}, {@link DataObject->setCastedField()} @@ -214,7 +233,7 @@ abstract class DBField extends ModelData implements DBIndexable */ public function getDefaultValue(): mixed { - return $this->defaultVal; + return $this->defaultValue; } /** @@ -222,7 +241,7 @@ abstract class DBField extends ModelData implements DBIndexable */ public function setDefaultValue(mixed $defaultValue): static { - $this->defaultVal = $defaultValue; + $this->defaultValue = $defaultValue; return $this; } diff --git a/src/ORM/FieldType/DBFloat.php b/src/ORM/FieldType/DBFloat.php index 824ff9f0b..1c7479fe4 100644 --- a/src/ORM/FieldType/DBFloat.php +++ b/src/ORM/FieldType/DBFloat.php @@ -13,7 +13,7 @@ class DBFloat extends DBField { public function __construct(?string $name = null, float|int $defaultVal = 0) { - $this->defaultVal = is_float($defaultVal) ? $defaultVal : (float) 0; + $this->setDefaultValue(is_float($defaultVal) ? $defaultVal : (float) 0); parent::__construct($name); } @@ -57,7 +57,7 @@ class DBFloat extends DBField return $field; } - public function nullValue(): ?int + public function nullValue(): int { return 0; } diff --git a/src/ORM/FieldType/DBForeignKey.php b/src/ORM/FieldType/DBForeignKey.php index c586b891f..c46cd7db4 100644 --- a/src/ORM/FieldType/DBForeignKey.php +++ b/src/ORM/FieldType/DBForeignKey.php @@ -63,6 +63,11 @@ class DBForeignKey extends DBInt if ($record instanceof DataObject) { $this->object = $record; } + // Convert blank string to 0, this is sometimes required when calling DataObject::setCastedValue() + // after a form submission where the value is a blank string when no value is selected + if ($value === '') { + $value = 0; + } return parent::setValue($value, $record, $markChanged); } } diff --git a/src/ORM/FieldType/DBInt.php b/src/ORM/FieldType/DBInt.php index 0f46ddf54..59a7f5d1c 100644 --- a/src/ORM/FieldType/DBInt.php +++ b/src/ORM/FieldType/DBInt.php @@ -2,32 +2,46 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; use SilverStripe\Forms\FormField; use SilverStripe\Forms\NumericField; use SilverStripe\Model\List\ArrayList; use SilverStripe\ORM\DB; use SilverStripe\Model\List\SS_List; use SilverStripe\Model\ArrayData; +use SilverStripe\Model\ModelData; /** - * Represents a signed 32 bit integer field. + * Represents a signed 32 bit integer field + * + * Ints are always signed i.e. they can be negative + * Their range is -2147483648 to 2147483647 */ class DBInt extends DBField { + private static array $field_validators = [ + IntFieldValidator::class + ]; + public function __construct(?string $name = null, int $defaultVal = 0) { - $this->defaultVal = is_int($defaultVal) ? $defaultVal : 0; - + $this->setDefaultValue($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 getField($fieldName): mixed { - return (int) $this->value; + return $this->value; + } + + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value, $record, $markChanged); + // Cast int like strings as ints + if (is_string($this->value) && preg_match('/^-?\d+$/', $this->value)) { + $this->value = (int) $value; + } + return $this; } /** @@ -71,7 +85,7 @@ class DBInt extends DBField return NumericField::create($this->name, $title); } - public function nullValue(): ?int + public function nullValue(): int { return 0; } diff --git a/src/ORM/FieldType/DBIp.php b/src/ORM/FieldType/DBIp.php new file mode 100644 index 000000000..ecd28a0dd --- /dev/null +++ b/src/ORM/FieldType/DBIp.php @@ -0,0 +1,13 @@ + null, + // enable multi enum field validator + MultiEnumFieldValidator::class => ['getEnum'], + ]; + public function __construct($name = null, $enum = null, $default = null) { // MultiEnum needs to take care of its own defaults @@ -34,6 +43,15 @@ class DBMultiEnum extends DBEnum } } + public function getValueForValidation(): array + { + $value = parent::getValueForValidation(); + if (is_array($value)) { + return $value; + } + return explode(',', (string) $value); + } + public function requireField(): void { $charset = Config::inst()->get(MySQLDatabase::class, 'charset'); diff --git a/src/ORM/FieldType/DBPolymorphicForeignKey.php b/src/ORM/FieldType/DBPolymorphicForeignKey.php index 62b792606..8fad8ad43 100644 --- a/src/ORM/FieldType/DBPolymorphicForeignKey.php +++ b/src/ORM/FieldType/DBPolymorphicForeignKey.php @@ -5,12 +5,18 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\Forms\FormField; use SilverStripe\ORM\DataObject; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\CompositeFieldValidator; /** * A special ForeignKey class that handles relations with arbitrary class types */ class DBPolymorphicForeignKey extends DBComposite { + private static array $field_validators = [ + // Disable parent field validator + CompositeFieldValidator::class => null, + ]; + private static bool $index = true; private static array $composite_db = [ diff --git a/src/ORM/FieldType/DBString.php b/src/ORM/FieldType/DBString.php index 99d597aa9..9a1371a1a 100644 --- a/src/ORM/FieldType/DBString.php +++ b/src/ORM/FieldType/DBString.php @@ -2,11 +2,18 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator; + /** * An abstract base class for the string field types (i.e. Varchar and Text) */ abstract class DBString extends DBField { + private static array $field_validators = [ + StringFieldValidator::class, + ]; + private static array $casting = [ 'LimitCharacters' => 'Text', 'LimitCharactersToClosestWord' => 'Text', @@ -17,13 +24,14 @@ abstract class DBString extends DBField ]; /** - * Set the default value for "nullify empty" + * Set the default value for "nullify empty" and 'default' * * {@inheritDoc} */ public function __construct($name = null, $options = []) { $this->options['nullifyEmpty'] = true; + $this->options['default'] = ''; parent::__construct($name, $options); } @@ -82,6 +90,16 @@ abstract class DBString extends DBField return $value || (is_string($value) && strlen($value ?? '')); } + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + if (is_null($value)) { + $this->value = ''; + } else { + $this->value = $value; + } + return $this; + } + public function prepValueForDB(mixed $value): array|string|null { // Cast non-empty value diff --git a/src/ORM/FieldType/DBTime.php b/src/ORM/FieldType/DBTime.php index fac285be1..7915336b1 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\Core\Validation\FieldValidation\TimeFieldValidator; /** * 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 = [ + TimeFieldValidator::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/DBUrl.php b/src/ORM/FieldType/DBUrl.php new file mode 100644 index 000000000..ab9435c65 --- /dev/null +++ b/src/ORM/FieldType/DBUrl.php @@ -0,0 +1,22 @@ +name, $title); + $field->setMaxLength($this->getSize()); + return $field; + } +} diff --git a/src/ORM/FieldType/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php index 3081ad34b..b0cbbc017 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\Core\Validation\FieldValidation\StringFieldValidator; /** * 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 = [ + StringFieldValidator::class => [null, 'getSize'], + ]; + private static array $casting = [ 'Initial' => 'Text', 'URL' => 'Text', diff --git a/src/ORM/FieldType/DBYear.php b/src/ORM/FieldType/DBYear.php index 04618cae3..f3fa05622 100644 --- a/src/ORM/FieldType/DBYear.php +++ b/src/ORM/FieldType/DBYear.php @@ -5,12 +5,23 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; use SilverStripe\ORM\DB; +use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; /** - * Represents a single year field. + * Represents a single year field */ class DBYear extends DBField { + // MySQL year datatype supports years between 1901 and 2155 + // https://dev.mysql.com/doc/refman/8.0/en/year.html + private const MIN_YEAR = 1901; + private const MAX_YEAR = 2155; + + private static $field_validators = [ + IntFieldValidator::class => ['getMinYear', 'getMaxYear'], + ]; + public function requireField(): void { $parts = ['datatype' => 'year', 'precision' => 4, 'arrayValue' => $this->arrayValue]; @@ -25,11 +36,56 @@ class DBYear extends DBField return $selectBox; } + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value, $record, $markChanged); + // 0 is used to represent a null value, which will be stored as 0000 in MySQL + if ($this->value === '0000') { + $this->value = 0; + } + // shorthand for 2000 in MySQL + if ($this->value === '00') { + $this->value = 2000; + } + // convert string int to int + // string int and int are both valid in MySQL, though only use int internally + if (is_string($this->value) && preg_match('#^\d+$#', (string) $this->value)) { + $this->value = (int) $this->value; + } + if (!is_int($this->value)) { + return $this; + } + // shorthand for 2001-2069 in MySQL + if ($this->value >= 1 && $this->value <= 69) { + $this->value = 2000 + $this->value; + } + // shorthand for 1970-1999 in MySQL + if ($this->value >= 70 && $this->value <= 99) { + $this->value = 1900 + $this->value; + } + return $this; + } + + public function nullValue(): int + { + return 0; + } + + public function getMinYear(): int + { + return DBYear::MIN_YEAR; + } + + public function getMaxYear(): int + { + return DBYear::MAX_YEAR; + } + /** * Returns a list of default options that can * be used to populate a select box, or compare against * input values. Starts by default at the current year, - * and counts back to 1900. + * and counts back to 1901. * * @param int|null $start starting date to count down from * @param int|null $end end date to count down to @@ -37,10 +93,10 @@ class DBYear extends DBField private function getDefaultOptions(?int $start = null, ?int $end = null): array { if (!$start) { - $start = (int)date('Y'); + $start = (int) date('Y'); } if (!$end) { - $end = 1900; + $end = DBYear::MIN_YEAR; } $years = []; for ($i = $start; $i >= $end; $i--) { diff --git a/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php new file mode 100644 index 000000000..479068540 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php @@ -0,0 +1,75 @@ + [ + 'value' => 123, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'expected' => true, + ], + 'valid-max-int' => [ + 'value' => 9223372036854775807, + 'expected' => true, + ], + 'valid-min-int' => [ + 'value' => '-9223372036854775808', + 'expected' => true, + ], + // Note: cannot test out of range values as they casting them to int + // will change the value to PHP_INT_MIN/PHP_INT_MAX + 'invalid-string-int' => [ + 'value' => '123', + 'expected' => false, + ], + 'invalid-float' => [ + 'value' => 123.45, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + // On 64-bit systems, -9223372036854775808 will end up as a float + // however it works correctly when cast to an int + if ($value === '-9223372036854775808') { + $value = (int) $value; + } + $validator = new BigIntFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/BooleanIntFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/BooleanIntFieldValidatorTest.php new file mode 100644 index 000000000..eb0b0bce8 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/BooleanIntFieldValidatorTest.php @@ -0,0 +1,72 @@ + [ + 'value' => 1, + 'expected' => true, + ], + 'valid-int-0' => [ + 'value' => 0, + 'expected' => true, + ], + 'invvalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + 'invalid-string-1' => [ + 'value' => '1', + 'expected' => false, + ], + 'invalid-string-0' => [ + 'value' => '0', + 'expected' => false, + ], + 'invalid-string-true' => [ + 'value' => 'true', + 'expected' => false, + ], + 'invalid-string-false' => [ + 'value' => 'false', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-string' => [ + 'value' => 'abc', + 'expected' => false, + ], + 'invalid-int' => [ + 'value' => 123, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [], + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new BooleanIntFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/CompositeFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/CompositeFieldValidatorTest.php new file mode 100644 index 000000000..b8c12004f --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/CompositeFieldValidatorTest.php @@ -0,0 +1,97 @@ + [ + 'valueBoolean' => true, + 'valueString' => 'fish', + 'valueIsNull' => false, + 'exception' => null, + 'expected' => true, + ], + 'exception-not-iterable' => [ + 'valueBoolean' => true, + 'valueString' => 'not-iterable', + 'valueIsNull' => false, + 'exception' => InvalidArgumentException::class, + 'expected' => true, + ], + 'exception-not-field-validator' => [ + 'valueBoolean' => true, + 'valueString' => 'no-field-validation', + 'valueIsNull' => false, + 'exception' => InvalidArgumentException::class, + 'expected' => true, + ], + 'exception-do-not-skip-null' => [ + 'valueBoolean' => true, + 'valueString' => 'fish', + 'valueIsNull' => true, + 'exception' => InvalidArgumentException::class, + 'expected' => true, + ], + 'invalid-bool-field' => [ + 'valueBoolean' => 'dog', + 'valueString' => 'fish', + 'valueIsNull' => false, + 'exception' => null, + 'expected' => false, + ], + 'invalid-string-field' => [ + 'valueBoolean' => true, + 'valueString' => 456.789, + 'valueIsNull' => false, + 'exception' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate( + mixed $valueBoolean, + mixed $valueString, + bool $valueIsNull, + ?string $exception, + bool $expected + ): void { + if ($exception) { + $this->expectException($exception); + } + if ($valueIsNull) { + $iterable = null; + } else { + $booleanField = new DBBoolean('BooleanField'); + $booleanField->setValue($valueBoolean); + if ($exception && $valueString === 'no-field-validation') { + $stringField = new stdClass(); + } else { + $stringField = new DBVarchar('StringField'); + $stringField->setValue($valueString); + } + if ($exception && $valueString === 'not-iterable') { + $iterable = 'banana'; + } else { + $iterable = [$booleanField, $stringField]; + } + } + $validator = new CompositeFieldValidator('MyField', $iterable); + $result = $validator->validate(); + if (!$exception) { + $this->assertSame($expected, $result->isValid()); + } + } +} diff --git a/tests/php/Core/Validation/FieldValidation/DateFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/DateFieldValidatorTest.php new file mode 100644 index 000000000..4939be64e --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/DateFieldValidatorTest.php @@ -0,0 +1,48 @@ + [ + 'value' => '2020-09-15', + 'expected' => true, + ], + 'invalid' => [ + 'value' => '2020-02-30', + 'expected' => false, + ], + 'invalid-wrong-format' => [ + 'value' => '15-09-2020', + 'expected' => false, + ], + 'invalid-date-time' => [ + 'value' => '2020-09-15 13:34:56', + 'expected' => false, + ], + 'invalid-time' => [ + 'value' => '13:34:56', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new DateFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php new file mode 100644 index 000000000..2ac3d3c76 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php @@ -0,0 +1,52 @@ + [ + 'value' => '2020-09-15 13:34:56', + 'expected' => true, + ], + 'invalid-date' => [ + 'value' => '2020-02-30 13:34:56', + 'expected' => false, + ], + 'invalid-time' => [ + 'value' => '2020-02-15 13:99:56', + 'expected' => false, + ], + 'invalid-wrong-format' => [ + 'value' => '15-09-2020 13:34:56', + 'expected' => false, + ], + 'invalid-date-only' => [ + 'value' => '2020-09-15', + 'expected' => false, + ], + 'invalid-time-only' => [ + 'value' => '13:34:56', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new DatetimeFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/DecimalFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/DecimalFieldValidatorTest.php new file mode 100644 index 000000000..176f8f520 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/DecimalFieldValidatorTest.php @@ -0,0 +1,138 @@ + [ + 'value' => 123.45, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-negative' => [ + 'value' => -123.45, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-rounded-dp' => [ + 'value' => 123.456, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-rounded-up' => [ + 'value' => 123.999, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-int' => [ + 'value' => 123, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-max' => [ + 'value' => 999.99, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-max-negative' => [ + 'value' => -999.99, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'invalid-rounded-to-6-digts' => [ + 'value' => 999.999, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-too-long' => [ + 'value' => 1234.56, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-too-long-3dp' => [ + 'value' => 123.456, + 'wholeSize' => 5, + 'decimalSize' => 3, + 'expected' => false, + ], + 'invalid-too-long-1dp' => [ + 'value' => 123.4, + 'wholeSize' => 5, + 'decimalSize' => 3, + 'expected' => false, + ], + 'invalid-too-long-int' => [ + 'value' => 123, + 'wholeSize' => 5, + 'decimalSize' => 3, + 'expected' => false, + ], + 'invalid-string' => [ + 'value' => '123.45', + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123.45], + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, int $wholeSize, int $decimalSize, bool $expected): void + { + $validator = new DecimalFieldValidator('MyField', $value, $wholeSize, $decimalSize); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php new file mode 100644 index 000000000..2885bdf36 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php @@ -0,0 +1,37 @@ + [ + 'value' => 'test@example.com', + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'fish', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new EmailFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/EnumFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/EnumFieldValidatorTest.php new file mode 100644 index 000000000..60cea876e --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/EnumFieldValidatorTest.php @@ -0,0 +1,54 @@ + [ + 'value' => 'cat', + 'allowedValues' => ['cat', 'dog'], + 'expected' => true, + ], + 'valid-int' => [ + 'value' => 123, + 'allowedValues' => [123, 456], + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'fish', + 'allowedValues' => ['cat', 'dog'], + 'expected' => false, + ], + 'invalid-none' => [ + 'value' => '', + 'allowedValues' => ['cat', 'dog'], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'allowedValues' => ['cat', 'dog'], + 'expected' => false, + ], + 'invalid-strict' => [ + 'value' => '123', + 'allowedValues' => [123, 456], + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, array $allowedValues, bool $expected): void + { + $validator = new EnumFieldValidator('MyField', $value, $allowedValues); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php new file mode 100644 index 000000000..a18f3b4a8 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php @@ -0,0 +1,76 @@ + [ + 'value' => 123, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'expected' => true, + ], + 'valid-max-int' => [ + 'value' => 2147483647, + 'expected' => true, + ], + 'valid-min-int' => [ + 'value' => -2147483648, + 'expected' => true, + ], + 'invalid-out-of-bounds' => [ + 'value' => 2147483648, + 'expected' => false, + ], + 'invalid-out-of-negative-bounds' => [ + 'value' => -2147483649, + 'expected' => false, + ], + 'invalid-string-int' => [ + 'value' => '123', + 'expected' => false, + ], + 'invalid-float' => [ + 'value' => 123.45, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new IntFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php new file mode 100644 index 000000000..9a91614cc --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php @@ -0,0 +1,45 @@ + [ + 'value' => '127.0.0.1', + 'expected' => true, + ], + 'valid-ipv6' => [ + 'value' => '0:0:0:0:0:0:0:1', + 'expected' => true, + ], + 'valid-ipv6-short' => [ + 'value' => '::1', + 'expected' => true, + ], + 'invalid' => [ + 'value' => '12345', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new IpFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php new file mode 100644 index 000000000..cab128a7b --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php @@ -0,0 +1,61 @@ + [ + 'value' => 'de_DE', + 'expected' => true, + ], + 'valid-dash' => [ + 'value' => 'de-DE', + 'expected' => true, + ], + 'valid-short' => [ + 'value' => 'de', + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'zz_ZZ', + 'expected' => false, + ], + 'invalid-dash' => [ + 'value' => 'zz-ZZ', + 'expected' => false, + ], + 'invalid-short' => [ + 'value' => 'zz', + 'expected' => false, + ], + 'invalid-dashes' => [ + 'value' => '-----', + 'expected' => false, + ], + 'invalid-donut' => [ + 'value' => 'donut', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new LocaleFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php new file mode 100644 index 000000000..8e286b371 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php @@ -0,0 +1,84 @@ + [ + 'value' => ['cat'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => true, + ], + 'valid-multi-string' => [ + 'value' => ['cat', 'dog'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => true, + ], + 'valid-none' => [ + 'value' => [], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => true, + ], + 'valid-int' => [ + 'value' => [123], + 'allowedValues' => [123, 456], + 'exception' => false, + 'expected' => true, + ], + 'exception-not-array' => [ + 'value' => 'cat,dog', + 'allowedValues' => ['cat', 'dog'], + 'exception' => true, + 'expected' => false, + ], + 'invalid' => [ + 'value' => ['fish'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => [null], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => false, + ], + 'invalid-multi' => [ + 'value' => ['dog', 'fish'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => false, + ], + 'invalid-strict' => [ + 'value' => ['123'], + 'allowedValues' => [123, 456], + 'exception' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, array $allowedValues, bool $exception, bool $expected): void + { + if ($exception) { + $this->expectException(InvalidArgumentException::class); + } + $validator = new MultiEnumFieldValidator('MyField', $value, $allowedValues); + $result = $validator->validate(); + if (!$exception) { + $this->assertSame($expected, $result->isValid()); + } + } +} diff --git a/tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php new file mode 100644 index 000000000..a0569eaa7 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php @@ -0,0 +1,80 @@ + [ + 'value' => 123, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'expected' => true, + ], + 'valid-float' => [ + 'value' => 123.45, + 'expected' => true, + ], + 'valid-negative-float' => [ + 'value' => -123.45, + 'expected' => true, + ], + 'valid-max-int' => [ + 'value' => PHP_INT_MAX, + 'expected' => true, + ], + 'valid-min-int' => [ + 'value' => PHP_INT_MIN, + 'expected' => true, + ], + 'valid-max-float' => [ + 'value' => PHP_FLOAT_MAX, + 'expected' => true, + ], + 'valid-min-float' => [ + 'value' => PHP_FLOAT_MIN, + 'expected' => true, + ], + 'invalid-string' => [ + 'value' => '123', + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new NumericFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php new file mode 100644 index 000000000..7d5946e79 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php @@ -0,0 +1,149 @@ + [ + 'value' => 'fish', + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-blank' => [ + 'value' => '', + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-blank-when-min' => [ + 'value' => '', + 'minLength' => 5, + 'maxLength' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-max' => [ + 'value' => 'fish', + 'minLength' => 0, + 'maxLength' => 4, + 'exception' => false, + 'expected' => true, + ], + 'valid-less-than-max-null-min' => [ + 'value' => 'fish', + 'minLength' => null, + 'maxLength' => 4, + 'exception' => false, + 'expected' => true, + ], + 'valid-less-than-max-unicode' => [ + 'value' => '☕☕☕☕', + 'minLength' => 0, + 'maxLength' => 4, + 'exception' => false, + 'expected' => true, + ], + 'exception-negative-min' => [ + 'value' => 'fish', + 'minLength' => -1, + 'maxLength' => null, + 'exception' => true, + 'expected' => false, + ], + 'invalid-below-min' => [ + 'value' => 'fish', + 'minLength' => 5, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-below-min-unicode' => [ + 'value' => '☕☕☕☕', + 'minLength' => 5, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-above-min' => [ + 'value' => 'fish', + 'minLength' => 0, + 'maxLength' => 3, + 'exception' => false, + 'expected' => false, + ], + 'invalid-above-min-unicode' => [ + 'value' => '☕☕☕☕', + 'minLength' => 0, + 'maxLength' => 3, + 'exception' => false, + 'expected' => false, + ], + 'invalid-int' => [ + 'value' => 123, + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-float' => [ + 'value' => 123.56, + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => ['fish'], + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, ?int $minLength, ?int $maxLength, bool $exception, bool $expected): void + { + if ($exception) { + $this->expectException(InvalidArgumentException::class); + } + $validator = new StringFieldValidator('MyField', $value, $minLength, $maxLength); + $result = $validator->validate(); + if (!$exception) { + $this->assertSame($expected, $result->isValid()); + } + } +} diff --git a/tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php new file mode 100644 index 000000000..e2026d919 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php @@ -0,0 +1,48 @@ + [ + 'value' => '13:34:56', + 'expected' => true, + ], + 'invalid' => [ + 'value' => '13:99:56', + 'expected' => false, + ], + 'invalid-wrong-format' => [ + 'value' => '13-34-56', + 'expected' => false, + ], + 'invalid-date-time' => [ + 'value' => '2020-09-15 13:34:56', + 'expected' => false, + ], + 'invalid-date' => [ + 'value' => '2020-09-15', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new TimeFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php new file mode 100644 index 000000000..f5b463eb2 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php @@ -0,0 +1,45 @@ + [ + 'value' => 'https://www.example.com', + 'expected' => true, + ], + 'valid-http' => [ + 'value' => 'https://www.example.com', + 'expected' => true, + ], + 'invalid-ftp' => [ + 'value' => 'ftp://www.example.com', + 'expected' => false, + ], + 'invalid-no-scheme' => [ + 'value' => 'www.example.com', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new UrlFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Forms/TextFieldTest.php b/tests/php/Forms/TextFieldTest.php index 77acc0461..e778b4da1 100644 --- a/tests/php/Forms/TextFieldTest.php +++ b/tests/php/Forms/TextFieldTest.php @@ -6,6 +6,7 @@ use SilverStripe\Dev\SapphireTest; use SilverStripe\Forms\TextField; use SilverStripe\Forms\RequiredFields; use SilverStripe\Forms\Tip; +use PHPUnit\Framework\Attributes\DataProvider; class TextFieldTest extends SapphireTest { @@ -45,4 +46,42 @@ class TextFieldTest extends SapphireTest $textField->setTip(new Tip('TestTip')); $this->assertArrayHasKey('tip', $textField->getSchemaDataDefaults()); } + + public static function provideSetValue(): array + { + return [ + 'string' => [ + 'value' => 'fish', + 'expected' => 'fish', + ], + 'string-blank' => [ + 'value' => '', + 'expected' => '', + ], + 'null' => [ + 'value' => null, + 'expected' => '', + ], + 'zero' => [ + 'value' => 0, + 'expected' => 0, + ], + 'true' => [ + 'value' => true, + 'expected' => true, + ], + 'false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new TextField('TestField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } } diff --git a/tests/php/ORM/DBCompositeTest.php b/tests/php/ORM/DBCompositeTest.php index 6f096628b..c8f221cf1 100644 --- a/tests/php/ORM/DBCompositeTest.php +++ b/tests/php/ORM/DBCompositeTest.php @@ -6,6 +6,9 @@ use SilverStripe\ORM\FieldType\DBMoney; use SilverStripe\ORM\DataObject; use SilverStripe\Dev\SapphireTest; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\ORM\FieldType\DBVarchar; +use SilverStripe\ORM\FieldType\DBDecimal; class DBCompositeTest extends SapphireTest { @@ -140,4 +143,12 @@ class DBCompositeTest extends SapphireTest // $this->assertSame($moneyField, $obj->dbObject('DoubleMoney')); // $this->assertEquals(20, $obj->dbObject('DoubleMoney')->getAmount()); } + + public function testGetValueForValidation(): void + { + $obj = DBCompositeTest\DBDoubleMoney::create(); + $expected = [DBVarchar::class, DBDecimal::class]; + $actual = array_map('get_class', $obj->getValueForValidation()); + $this->assertSame($expected, $actual); + } } diff --git a/tests/php/ORM/DBEnumTest.php b/tests/php/ORM/DBEnumTest.php index a44fbc57f..49117822c 100644 --- a/tests/php/ORM/DBEnumTest.php +++ b/tests/php/ORM/DBEnumTest.php @@ -141,4 +141,38 @@ class DBEnumTest extends SapphireTest $colourField->getEnumObsolete() ); } + + public static function provideSetValue(): array + { + return [ + 'string' => [ + 'value' => 'green', + 'expected' => 'green', + ], + 'string-not-in-set' => [ + 'value' => 'purple', + 'expected' => 'purple', + ], + 'int' => [ + 'value' => 123, + 'expected' => 123, + ], + 'empty-string' => [ + 'value' => '', + 'expected' => 'blue', + ], + 'null' => [ + 'value' => null, + 'expected' => 'blue', + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBEnum('TestField', ['red', 'green', 'blue'], 'blue'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } } diff --git a/tests/php/ORM/DBFieldTest.php b/tests/php/ORM/DBFieldTest.php index b64cebf1b..b25558a22 100644 --- a/tests/php/ORM/DBFieldTest.php +++ b/tests/php/ORM/DBFieldTest.php @@ -2,6 +2,7 @@ namespace SilverStripe\ORM\Tests; +use Exception; use SilverStripe\Assets\Image; use SilverStripe\ORM\FieldType\DBBigInt; use SilverStripe\ORM\FieldType\DBBoolean; @@ -30,6 +31,33 @@ use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBYear; use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Core\ClassInfo; +use ReflectionClass; +use SilverStripe\Core\Validation\FieldValidation\BooleanIntFieldValidator; +use SilverStripe\Dev\TestOnly; +use SilverStripe\Core\Validation\FieldValidation\BigIntFieldValidator; +use SilverStripe\ORM\FieldType\DBClassName; +use ReflectionMethod; +use SilverStripe\Core\Validation\FieldValidation\CompositeFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\DecimalFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\EmailFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\EnumFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\IpFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\LocaleFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\MultiEnumFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\TimeFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\UrlFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\YearFieldValidator; +use SilverStripe\ORM\FieldType\DBUrl; +use SilverStripe\ORM\FieldType\DBPolymorphicRelationAwareForeignKey; +use SilverStripe\ORM\FieldType\DBIp; +use SilverStripe\ORM\FieldType\DBEmail; +use SilverStripe\Core\Validation\FieldValidation\DatetimeFieldValidator; +use SilverStripe\Assets\Storage\DBFile; +use SilverStripe\ORM\FieldType\DBClassNameVarchar; /** * Tests for DBField objects. @@ -392,4 +420,154 @@ class DBFieldTest extends SapphireTest $this->assertEquals('new value', $obj->getField('MyTestField')); } + + public function testDefaultValues(): void + { + $expectedBaseDefault = null; + $expectedDefaults = [ + DBBoolean::class => 0, + DBDecimal::class => 0.0, + DBInt::class => 0, + DBFloat::class => 0.0, + ]; + $classes = ClassInfo::subclassesFor(DBField::class); + foreach ($classes as $class) { + if (is_a($class, TestOnly::class, true)) { + continue; + } + $reflector = new ReflectionClass($class); + if ($reflector->isAbstract()) { + continue; + } + $expected = $expectedBaseDefault; + foreach ($expectedDefaults as $baseClass => $default) { + if ($class === $baseClass || is_subclass_of($class, $baseClass)) { + $expected = $default; + break; + } + } + $field = new $class('TestField'); + $this->assertSame($expected, $field->getValue(), $class); + } + } + + public function testFieldValidatorConfig(): void + { + $expectedFieldValidators = [ + DBBigInt::class => [ + BigIntFieldValidator::class, + ], + DBBoolean::class => [ + BooleanIntFieldValidator::class, + ], + DBClassName::class => [ + StringFieldValidator::class, + EnumFieldValidator::class, + ], + DBClassNameVarchar::class => [ + StringFieldValidator::class, + EnumFieldValidator::class, + ], + DBCurrency::class => [ + DecimalFieldValidator::class, + ], + DBDate::class => [ + DateFieldValidator::class, + ], + DBDatetime::class => [ + DatetimeFieldValidator::class, + ], + DBDecimal::class => [ + DecimalFieldValidator::class, + ], + DBDouble::class => [], + DBEmail::class => [ + StringFieldValidator::class, + EmailFieldValidator::class, + ], + DBEnum::class => [ + StringFieldValidator::class, + EnumFieldValidator::class, + ], + DBFloat::class => [], + DBForeignKey::class => [ + IntFieldValidator::class, + ], + DBHTMLText::class => [ + StringFieldValidator::class, + ], + DBHTMLVarchar::class => [ + StringFieldValidator::class, + ], + DBInt::class => [ + IntFieldValidator::class, + ], + DBIp::class => [ + StringFieldValidator::class, + IpFieldValidator::class, + ], + DBLocale::class => [ + StringFieldValidator::class, + LocaleFieldValidator::class, + ], + DBMoney::class => [ + CompositeFieldValidator::class, + ], + DBMultiEnum::class => [ + StringFieldValidator::class, + MultiEnumFieldValidator::class, + ], + DBPercentage::class => [ + DecimalFieldValidator::class, + ], + DBPolymorphicForeignKey::class => [], + DBPolymorphicRelationAwareForeignKey::class => [], + DBPrimaryKey::class => [ + IntFieldValidator::class, + ], + DBText::class => [ + StringFieldValidator::class, + ], + DBTime::class => [ + TimeFieldValidator::class, + ], + DBUrl::class => [ + StringFieldValidator::class, + UrlFieldValidator::class, + ], + DBVarchar::class => [ + StringFieldValidator::class, + ], + DBYear::class => [ + YearFieldValidator::class, + ], + ]; + $count = 0; + $classes = ClassInfo::subclassesFor(DBField::class); + foreach ($classes as $class) { + if (is_a($class, TestOnly::class, true)) { + continue; + } + if (!str_starts_with($class, 'SilverStripe\ORM\FieldType')) { + continue; + } + $reflector = new ReflectionClass($class); + if ($reflector->isAbstract()) { + continue; + } + if (!array_key_exists($class, $expectedFieldValidators)) { + throw new Exception("No field validator config found for $class"); + } + $expected = $expectedFieldValidators[$class]; + $method = new ReflectionMethod($class, 'getFieldValidators'); + $method->setAccessible(true); + $obj = new $class('MyField'); + $actual = array_map('get_class', $method->invoke($obj)); + $this->assertSame($expected, $actual, $class); + $count++; + } + // Assert that we have tested all classes e.g. namespace wasn't changed, no new classes were added + // that haven't been tested + $this->assertSame(29, $count); + } } diff --git a/tests/php/ORM/DBForiegnKeyTest.php b/tests/php/ORM/DBForiegnKeyTest.php new file mode 100644 index 000000000..2eecaa495 --- /dev/null +++ b/tests/php/ORM/DBForiegnKeyTest.php @@ -0,0 +1,44 @@ + [ + 'value' => 2, + 'expected' => 2, + ], + 'string' => [ + 'value' => '2', + 'expected' => '2', + ], + 'zero' => [ + 'value' => 0, + 'expected' => 0, + ], + 'blank-string' => [ + 'value' => '', + 'expected' => 0, + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBForeignKey('TestField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } +} diff --git a/tests/php/ORM/DBIntTest.php b/tests/php/ORM/DBIntTest.php index 554d80232..c44348ebf 100644 --- a/tests/php/ORM/DBIntTest.php +++ b/tests/php/ORM/DBIntTest.php @@ -4,15 +4,78 @@ namespace SilverStripe\ORM\Tests; use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\FieldType\DBInt; +use PHPUnit\Framework\Attributes\DataProvider; class DBIntTest extends SapphireTest { - public function testGetValueCastToInt() + public function testDefaultValue(): void { - $field = DBInt::create('MyField'); - $field->setValue(3); - $this->assertSame(3, $field->getValue()); - $field->setValue('3'); - $this->assertSame(3, $field->getValue()); + $field = new DBInt('MyField'); + $this->assertSame(0, $field->getValue()); + } + + public static function provideSetValue(): array + { + return [ + 'int' => [ + 'value' => 3, + 'expected' => 3, + ], + 'string-int' => [ + 'value' => '3', + 'expected' => 3, + ], + 'negative-int' => [ + 'value' => -3, + 'expected' => -3, + ], + 'negative-string-int' => [ + 'value' => '-3', + 'expected' => -3, + ], + 'string' => [ + 'value' => 'fish', + 'expected' => 'fish', + ], + 'array' => [ + 'value' => [], + 'expected' => [], + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBInt('MyField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } + + public static function provideValidate(): array + { + return [ + 'valid' => [ + 'value' => 123, + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'abc', + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $field = new DBInt('MyField'); + $field->setValue($value); + $result = $field->validate(); + $this->assertSame($expected, $result->isValid()); } } diff --git a/tests/php/ORM/DBMultiEnumTest.php b/tests/php/ORM/DBMultiEnumTest.php new file mode 100644 index 000000000..813502654 --- /dev/null +++ b/tests/php/ORM/DBMultiEnumTest.php @@ -0,0 +1,44 @@ + [ + 'value' => ['Red', 'Green'], + 'expected' => ['Red', 'Green'], + ], + 'string' => [ + 'value' => 'Red,Green', + 'expected' => ['Red', 'Green'], + ], + 'string-non-existant-value' => [ + 'value' => 'Red,Green,Purple', + 'expected' => ['Red', 'Green', 'Purple'], + ], + 'empty-string' => [ + 'value' => '', + 'expected' => [''], + ], + 'null' => [ + 'value' => null, + 'expected' => [''], + ], + ]; + } + + #[DataProvider('provideGetValueForValidation')] + public function testGetValueForValidation(mixed $value, array $expected): void + { + $obj = new DBMultiEnum('TestField', ['Red', 'Green', 'Blue']); + $obj->setValue($value); + $this->assertSame($expected, $obj->getValueForValidation()); + } +} diff --git a/tests/php/ORM/DBStringTest.php b/tests/php/ORM/DBStringTest.php index 81a4d3816..75dffc5b4 100644 --- a/tests/php/ORM/DBStringTest.php +++ b/tests/php/ORM/DBStringTest.php @@ -7,6 +7,7 @@ use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBString; use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\Tests\DBStringTest\MyStringField; +use PHPUnit\Framework\Attributes\DataProvider; class DBStringTest extends SapphireTest { @@ -68,4 +69,30 @@ class DBStringTest extends SapphireTest $this->assertFalse(DBField::create_field(MyStringField::class, 0)->exists()); $this->assertFalse(DBField::create_field(MyStringField::class, 0.0)->exists()); } + + public static function provideSetValue(): array + { + return [ + 'string' => [ + 'value' => 'fish', + 'expected' => 'fish', + ], + 'blank-string' => [ + 'value' => '', + 'expected' => '', + ], + 'null' => [ + 'value' => null, + 'expected' => '', + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, string $expected): void + { + $obj = new MyStringField('TestField'); + $obj->setValue($value); + $this->assertSame($expected, $obj->getValue()); + } } diff --git a/tests/php/ORM/DBYearTest.php b/tests/php/ORM/DBYearTest.php index 79632bae3..710745185 100755 --- a/tests/php/ORM/DBYearTest.php +++ b/tests/php/ORM/DBYearTest.php @@ -5,6 +5,7 @@ namespace SilverStripe\ORM\Tests; use SilverStripe\Forms\DropdownField; use SilverStripe\ORM\FieldType\DBYear; use SilverStripe\Dev\SapphireTest; +use PHPUnit\Framework\Attributes\DataProvider; class DBYearTest extends SapphireTest { @@ -18,15 +19,15 @@ class DBYearTest extends SapphireTest $field = $year->scaffoldFormField("YearTest"); $this->assertEquals(DropdownField::class, get_class($field)); - //This should be a list of years from the current one, counting down to 1900 + //This should be a list of years from the current one, counting down to 1901 $source = $field->getSource(); $lastValue = end($source); $lastKey = key($source ?? []); - //Keys and values should be the same - and the last one should be 1900 - $this->assertEquals(1900, $lastValue); - $this->assertEquals(1900, $lastKey); + //Keys and values should be the same - and the last one should be 1901 + $this->assertEquals(1901, $lastValue); + $this->assertEquals(1901, $lastKey); } public function testScaffoldFormFieldLast() @@ -43,4 +44,98 @@ class DBYearTest extends SapphireTest $this->assertEquals($currentYear, $firstValue); $this->assertEquals($currentYear, $firstKey); } + + public static function provideSetValue(): array + { + return [ + '4-int' => [ + 'value' => 2024, + 'expected' => 2024, + ], + '2-int' => [ + 'value' => 24, + 'expected' => 2024, + ], + '0-int' => [ + 'value' => 0, + 'expected' => 0, + ], + '4-string' => [ + 'value' => '2024', + 'expected' => 2024, + ], + '2-string' => [ + 'value' => '24', + 'expected' => 2024, + ], + '0-string' => [ + 'value' => '0', + 'expected' => 0, + ], + '00-string' => [ + 'value' => '00', + 'expected' => 2000, + ], + '0000-string' => [ + 'value' => '0000', + 'expected' => 0, + ], + '4-int-low' => [ + 'value' => 1900, + 'expected' => 1900, + ], + '4-int-low' => [ + 'value' => 2156, + 'expected' => 2156, + ], + '4-string-low' => [ + 'value' => '1900', + 'expected' => 1900, + ], + '4-string-low' => [ + 'value' => '2156', + 'expected' => 2156, + ], + 'int-negative' => [ + 'value' => -2024, + 'expected' => -2024, + ], + 'string-negative' => [ + 'value' => '-2024', + 'expected' => '-2024', + ], + 'float' => [ + 'value' => 2024.0, + 'expected' => 2024.0, + ], + 'string-float' => [ + 'value' => '2024.0', + 'expected' => '2024.0', + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + 'true' => [ + 'value' => true, + 'expected' => true, + ], + 'false' => [ + 'value' => false, + 'expected' => false, + ], + 'array' => [ + 'value' => [], + 'expected' => [], + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBYear('MyField'); + $result = $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } }