From 96145062bc88e5eb2acf1633a4236a26470a7a7c Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Tue, 29 Oct 2024 12:06:28 +1300 Subject: [PATCH] NEW Validate DBFields --- _config/model.yml | 6 + composer.json | 1 + .../FieldValidation/BigIntFieldValidator.php | 47 +++++ .../FieldValidation/BooleanFieldValidator.php | 22 ++ .../CompositeFieldValidator.php | 39 ++++ .../FieldValidation/DateFieldValidator.php | 41 ++++ .../DatetimeFieldValidator.php | 25 +++ .../FieldValidation/DecimalFieldValidator.php | 69 +++++++ .../FieldValidation/EmailFieldValidator.php | 24 +++ .../FieldValidationInterface.php | 18 ++ .../FieldValidation/FieldValidationTrait.php | 132 ++++++++++++ .../FieldValidation/FieldValidator.php | 55 +++++ .../FieldValidation/IntFieldValidator.php | 53 +++++ .../FieldValidation/IpFieldValidator.php | 27 +++ .../FieldValidation/LocaleFieldValidator.php | 24 +++ .../MultiOptionFieldValidator.php | 37 ++++ .../FieldValidation/NumericFieldValidator.php | 63 ++++++ .../FieldValidation/OptionFieldValidator.php | 39 ++++ .../FieldValidation/StringFieldValidator.php | 71 +++++++ .../SymfonyFieldValidatorInterface.php | 17 ++ .../SymfonyFieldValidatorTrait.php | 29 +++ .../FieldValidation/TimeFieldValidator.php | 25 +++ .../FieldValidation/UrlFieldValidator.php | 24 +++ .../FieldValidation/YearFieldValidator.php | 45 +++++ src/Core/Validation/ValidationInterface.php | 13 ++ src/Core/Validation/ValidationResult.php | 21 +- src/Forms/CompositeField.php | 4 +- src/Forms/FieldGroup.php | 2 +- src/Forms/FormField.php | 12 +- src/Forms/SelectionGroup_Item.php | 2 +- src/ORM/DataObject.php | 6 + src/ORM/FieldType/DBBigInt.php | 16 +- src/ORM/FieldType/DBBoolean.php | 68 +++++-- src/ORM/FieldType/DBClassNameVarchar.php | 5 + src/ORM/FieldType/DBComposite.php | 14 ++ src/ORM/FieldType/DBCurrency.php | 15 +- src/ORM/FieldType/DBDate.php | 6 + src/ORM/FieldType/DBDatetime.php | 10 + src/ORM/FieldType/DBDecimal.php | 48 +++-- src/ORM/FieldType/DBEmail.php | 22 ++ src/ORM/FieldType/DBEnum.php | 10 +- src/ORM/FieldType/DBField.php | 30 ++- src/ORM/FieldType/DBFloat.php | 6 +- src/ORM/FieldType/DBForeignKey.php | 15 ++ src/ORM/FieldType/DBInt.php | 25 ++- src/ORM/FieldType/DBIp.php | 13 ++ src/ORM/FieldType/DBLocale.php | 5 + src/ORM/FieldType/DBMultiEnum.php | 20 ++ src/ORM/FieldType/DBPercentage.php | 15 ++ src/ORM/FieldType/DBPolymorphicForeignKey.php | 1 + src/ORM/FieldType/DBString.php | 10 +- src/ORM/FieldType/DBTime.php | 6 + src/ORM/FieldType/DBUrl.php | 22 ++ src/ORM/FieldType/DBVarchar.php | 5 + src/ORM/FieldType/DBYear.php | 60 +++++- src/Security/Member.php | 1 - src/Security/Security.php | 6 + tests/behat/src/CmsUiContext.php | 1 + .../BigIntFieldValidatorTest.php | 75 +++++++ .../BooleanFieldValidatorTest.php | 72 +++++++ .../CompositeFieldValidatorTest.php | 97 +++++++++ .../DateFieldValidatorTest.php | 52 +++++ .../DatetimeFieldValidatorTest.php | 52 +++++ .../DecimalFieldValidatorTest.php | 138 +++++++++++++ .../EmailFieldValidatorTest.php | 37 ++++ .../FieldValidation/IntFieldValidatorTest.php | 76 +++++++ .../FieldValidation/IpFieldValidatorTest.php | 45 +++++ .../LocaleFieldValidatorTest.php | 61 ++++++ .../MultiOptionFieldValidatorTest.php | 84 ++++++++ .../NumericFieldValidatorTest.php | 170 ++++++++++++++++ .../OptionFieldValidatorTest.php | 54 +++++ .../StringFieldValidatorTest.php | 163 +++++++++++++++ .../TimeFieldValidatorTest.php | 48 +++++ .../FieldValidation/UrlFieldValidatorTest.php | 45 +++++ .../YearFieldValidatorTest.php | 47 +++++ tests/php/Forms/FormFieldTest.php | 1 + tests/php/ORM/DBBooleanTest.php | 98 +++++++++ tests/php/ORM/DBCompositeTest.php | 11 + tests/php/ORM/DBCurrencyTest.php | 80 +++++++- tests/php/ORM/DBDecimalTest.php | 87 ++++++++ tests/php/ORM/DBEnumTest.php | 54 ++++- tests/php/ORM/DBFieldTest.php | 189 +++++++++++++++++- tests/php/ORM/DBForiegnKeyTest.php | 44 ++++ tests/php/ORM/DBIntTest.php | 79 +++++++- tests/php/ORM/DBMultiEnumTest.php | 44 ++++ tests/php/ORM/DBStringTest.php | 1 + tests/php/ORM/DBYearTest.php | 103 +++++++++- tests/php/ORM/DecimalTest.php | 27 +-- tests/php/ORM/FieldType/DBEnumTestObject.php | 1 + 89 files changed, 3457 insertions(+), 126 deletions(-) create mode 100644 src/Core/Validation/FieldValidation/BigIntFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/BooleanFieldValidator.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/FieldValidationInterface.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidationTrait.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidator.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/MultiOptionFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/NumericFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/OptionFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/StringFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/SymfonyFieldValidatorInterface.php create mode 100644 src/Core/Validation/FieldValidation/SymfonyFieldValidatorTrait.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/FieldValidation/YearFieldValidator.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/BooleanFieldValidatorTest.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/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/MultiOptionFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/OptionFieldValidatorTest.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/Core/Validation/FieldValidation/YearFieldValidatorTest.php create mode 100644 tests/php/ORM/DBBooleanTest.php create mode 100644 tests/php/ORM/DBDecimalTest.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 4046feddb65..ca4d8e19361 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 a88e48e38c9..0f2e499e177 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,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/FieldValidation/BigIntFieldValidator.php b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php new file mode 100644 index 00000000000..8705d73f9f1 --- /dev/null +++ b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php @@ -0,0 +1,47 @@ +value)) { + $message = _t(__CLASS__ . '.INVALID', 'Invalid value'); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/CompositeFieldValidator.php b/src/Core/Validation/FieldValidation/CompositeFieldValidator.php new file mode 100644 index 00000000000..4cafa259575 --- /dev/null +++ b/src/Core/Validation/FieldValidation/CompositeFieldValidator.php @@ -0,0 +1,39 @@ +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 00000000000..0a2a3de22b3 --- /dev/null +++ b/src/Core/Validation/FieldValidation/DateFieldValidator.php @@ -0,0 +1,41 @@ +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 00000000000..9b7368067fc --- /dev/null +++ b/src/Core/Validation/FieldValidation/DatetimeFieldValidator.php @@ -0,0 +1,25 @@ +wholeSize = $wholeSize; + $this->decimalSize = $decimalSize; + } + + protected function validateValue(): ValidationResult + { + $result = parent::validateValue(); + if (!$result->isValid()) { + return $result; + } + // Convert to absolute value - the minus sign is not relevant for validation + $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', + 'Cannot have more than {wholeSize} digits, including after the decimal point', + ['wholeSize' => $this->wholeSize] + ); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/EmailFieldValidator.php b/src/Core/Validation/FieldValidation/EmailFieldValidator.php new file mode 100644 index 00000000000..2f39288ad34 --- /dev/null +++ b/src/Core/Validation/FieldValidation/EmailFieldValidator.php @@ -0,0 +1,24 @@ + [null, 'getSomething'], + * c) MyFieldValidator::class => null, + * + * a) Will create a MyFieldValidator and pass the name and value of the field as args to the constructor + * b) Will create a MyFieldValidator and pass the name, value, and pass additional args, where each null values + * will be passed as null, and non-null values will call a method on the field e.g. will pass null for the first + * additional arg and call $field->getSomething() to get a value for the second additional arg + * c) Will disable a previously set MyFieldValidator. This is useful to disable a FieldValidator that was set + * on a parent class + * + * You may only have a single instance of a given FieldValidator class per field, e.g. you can't have two + * instances of a `MyFieldValidator` class for the same field. + */ + private static array $field_validators = []; + + /** + * Validate this field using FieldValidators + */ + public function validate(): ValidationResult + { + $result = ValidationResult::create(); + $fieldValidators = $this->getFieldValidators(); + foreach ($fieldValidators as $fieldValidator) { + $result->combineAnd($fieldValidator->validate()); + } + return $result; + } + + /** + * Get instantiated FieldValidators based on `field_validators` configuration + */ + private function getFieldValidators(): array + { + $fieldValidators = []; + // Used to disable a validator that was previously set with an int index + if (!is_a($this, FieldValidationInterface::class)) { + $message = get_class($this) . ' must implement interface ' . FieldValidationInterface::class; + throw new RuntimeException($message); + } + /** @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 = $this->getClassesFromConfig(); + foreach ($classes as $class => $argCalls) { + $args = [$name, $value]; + foreach ($argCalls as $i => $argCall) { + if (is_null($argCall)) { + $args[] = null; + continue; + } + if (!is_string($argCall)) { + throw new RuntimeException("argCall $i for FieldValidator $class is not a string or null"); + } + if (!$this->hasMethod($argCall)) { + throw new RuntimeException("Method $argCall does not exist on " . get_class($this)); + } + $args[] = call_user_func([$this, $argCall]); + } + $fieldValidators[] = Injector::inst()->createWithArgs($class, $args); + } + return $fieldValidators; + } + + /** + * Get FieldValidator classes based on `field_validators` configuration + */ + private function getClassesFromConfig(): array + { + $classes = []; + $disabledClasses = []; + $config = static::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) { + // Disabling a class that was previously defined + $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]); + } + return $classes; + } +} diff --git a/src/Core/Validation/FieldValidation/FieldValidator.php b/src/Core/Validation/FieldValidation/FieldValidator.php new file mode 100644 index 00000000000..1b8d0dcbb4d --- /dev/null +++ b/src/Core/Validation/FieldValidation/FieldValidator.php @@ -0,0 +1,55 @@ +name = $name; + $this->value = $value; + } + + /** + * Validate the value + */ + public function validate(): ValidationResult + { + $result = ValidationResult::create(); + if (is_null($this->value) && $this->allowNull) { + return $result; + } + $validationResult = $this->validateValue($result); + if (!$validationResult->isValid()) { + $result->combineAnd($validationResult); + } + return $result; + } + + /** + * Inner validation method that performs the actual validation logic + */ + abstract protected function validateValue(): ValidationResult; +} diff --git a/src/Core/Validation/FieldValidation/IntFieldValidator.php b/src/Core/Validation/FieldValidation/IntFieldValidator.php new file mode 100644 index 00000000000..5e7a2b603be --- /dev/null +++ b/src/Core/Validation/FieldValidation/IntFieldValidator.php @@ -0,0 +1,53 @@ +value)) { + $message = _t(__CLASS__ . '.WRONGTYPE', 'Must be an integer'); + $result->addFieldError($this->name, $message); + } + if (!$result->isValid()) { + return $result; + } + $result->combineAnd(parent::validateValue()); + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/IpFieldValidator.php b/src/Core/Validation/FieldValidation/IpFieldValidator.php new file mode 100644 index 00000000000..96715da5b05 --- /dev/null +++ b/src/Core/Validation/FieldValidation/IpFieldValidator.php @@ -0,0 +1,27 @@ +value as $value) { + if (!in_array($value, $this->options, true)) { + $message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value'); + $result->addFieldError($this->name, $message); + break; + } + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/NumericFieldValidator.php b/src/Core/Validation/FieldValidation/NumericFieldValidator.php new file mode 100644 index 00000000000..c86f4f09e3b --- /dev/null +++ b/src/Core/Validation/FieldValidation/NumericFieldValidator.php @@ -0,0 +1,63 @@ +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, and not a string'); + $result->addFieldError($this->name, $message); + } elseif (!is_null($this->minValue) && $this->value < $this->minValue) { + $message = _t( + __CLASS__ . '.TOOSMALL', + 'Value cannot be less than {minValue}', + ['minValue' => $this->minValue] + ); + $result->addFieldError($this->name, $message); + } elseif (!is_null($this->maxValue) && $this->value > $this->maxValue) { + $message = _t( + __CLASS__ . '.TOOLARGE', + 'Value cannot be greater than {maxValue}', + ['maxValue' => $this->maxValue] + ); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/OptionFieldValidator.php b/src/Core/Validation/FieldValidation/OptionFieldValidator.php new file mode 100644 index 00000000000..f5bf433ec7b --- /dev/null +++ b/src/Core/Validation/FieldValidation/OptionFieldValidator.php @@ -0,0 +1,39 @@ +options = $options; + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + // Allow empty strings + // TODO: remove this, not sure why it's here + // should convert empty strings to null before validation if needed instead + if ($this->value === '') { + return $result; + } + if (!in_array($this->value, $this->options, true)) { + $message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value'); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/StringFieldValidator.php b/src/Core/Validation/FieldValidation/StringFieldValidator.php new file mode 100644 index 00000000000..9878bf8a60c --- /dev/null +++ b/src/Core/Validation/FieldValidation/StringFieldValidator.php @@ -0,0 +1,71 @@ +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); + 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); + } + 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); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/SymfonyFieldValidatorInterface.php b/src/Core/Validation/FieldValidation/SymfonyFieldValidatorInterface.php new file mode 100644 index 00000000000..6da7143144d --- /dev/null +++ b/src/Core/Validation/FieldValidation/SymfonyFieldValidatorInterface.php @@ -0,0 +1,17 @@ +isValid()) { + return $result; + } + if (!is_a($this, SymfonyFieldValidatorInterface::class)) { + $message = 'Classes using SymfonyFieldValidatorTrait must implement SymfonyFieldValidatorInterface'; + throw new LogicException($message); + } + $constraint = $this->getConstraint(); + $validationResult = ConstraintValidator::validate($this->value, $constraint, $this->name); + return $result->combineAnd($validationResult); + } +} diff --git a/src/Core/Validation/FieldValidation/TimeFieldValidator.php b/src/Core/Validation/FieldValidation/TimeFieldValidator.php new file mode 100644 index 00000000000..3af1f83b009 --- /dev/null +++ b/src/Core/Validation/FieldValidation/TimeFieldValidator.php @@ -0,0 +1,25 @@ +isValid()) { + return $result; + } + if ($this->value < DBYear::MIN_YEAR && $this->value !== YearFieldValidator::YEAR_EXCEPTION) { + $message = _t( + __CLASS__ . '.TOOSMALL', + 'Value cannot be less than {minYear} unless it is {yearException}', + [ + 'minYear' => DBYear::MIN_YEAR, + 'yearException' => YearFieldValidator::YEAR_EXCEPTION + ] + ); + $result->addFieldError($this->name, $message); + } + return $result; + } +} diff --git a/src/Core/Validation/ValidationInterface.php b/src/Core/Validation/ValidationInterface.php new file mode 100644 index 00000000000..a4c5362864b --- /dev/null +++ b/src/Core/Validation/ValidationInterface.php @@ -0,0 +1,13 @@ +addFieldError(null, $message, $messageType, $code, $cast); } @@ -96,7 +100,7 @@ public function addFieldError( $message, $messageType = ValidationResult::TYPE_ERROR, $code = null, - $cast = ValidationResult::CAST_TEXT + $cast = ValidationResult::CAST_TEXT, ) { $this->isValid = false; return $this->addFieldMessage($fieldName, $message, $messageType, $code, $cast); @@ -114,8 +118,12 @@ public function addFieldError( * Bool values will be treated as plain text flag. * @return $this */ - public function addMessage($message, $messageType = ValidationResult::TYPE_ERROR, $code = null, $cast = ValidationResult::CAST_TEXT) - { + public function addMessage( + $message, + $messageType = ValidationResult::TYPE_ERROR, + $code = null, + $cast = ValidationResult::CAST_TEXT, + ) { return $this->addFieldMessage(null, $message, $messageType, $code, $cast); } @@ -137,7 +145,7 @@ public function addFieldMessage( $message, $messageType = ValidationResult::TYPE_ERROR, $code = null, - $cast = ValidationResult::CAST_TEXT + $cast = ValidationResult::CAST_TEXT, ) { if ($code && is_numeric($code)) { throw new InvalidArgumentException("Don't use a numeric code '$code'. Use a string."); @@ -151,7 +159,6 @@ public function addFieldMessage( 'messageType' => $messageType, 'messageCast' => $cast, ]; - if ($code) { $this->messages[$code] = $metadata; } else { diff --git a/src/Forms/CompositeField.php b/src/Forms/CompositeField.php index 7c1cab23f10..57ea04cfb89 100644 --- a/src/Forms/CompositeField.php +++ b/src/Forms/CompositeField.php @@ -119,10 +119,8 @@ public function getChildren() * 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/FieldGroup.php b/src/Forms/FieldGroup.php index 9a0d6c67588..f7ced190398 100644 --- a/src/Forms/FieldGroup.php +++ b/src/Forms/FieldGroup.php @@ -106,7 +106,7 @@ public function __construct($titleOrField = null, $otherFields = null) * 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 0d210436b0f..00d9ca1b824 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -15,6 +15,7 @@ use SilverStripe\View\AttributesHTML; use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\FieldValidationTrait; /** * Represents a field in a form. @@ -44,6 +45,7 @@ class FormField extends RequestHandler { use AttributesHTML; use FormMessage; + use FieldValidationTrait; /** @see $schemaDataType */ const SCHEMA_DATA_TYPE_STRING = 'String'; @@ -424,12 +426,10 @@ public function getTemplateHelper() /** * Returns the field name. - * - * @return string */ - public function getName() + public function getName(): string { - return $this->name; + return $this->name ?? ''; } /** @@ -1231,8 +1231,8 @@ protected function extendValidationResult(bool $result, Validator $validator): b } /** - * Abstract method each {@link FormField} subclass must implement, determines whether the field - * is valid or not based on the value. + * Subclasses can define an existing FieldValidatorClass to validate the FormField value + * They may also override this method to provide custom validation logic * * @param Validator $validator * @return bool diff --git a/src/Forms/SelectionGroup_Item.php b/src/Forms/SelectionGroup_Item.php index 55d021859e7..ab23c0cc83d 100644 --- a/src/Forms/SelectionGroup_Item.php +++ b/src/Forms/SelectionGroup_Item.php @@ -43,7 +43,7 @@ function setTitle($title) return $this; } - function getValue() + function getValue(): mixed { return $this->value; } diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index e0d2b755d61..549e157e8fc 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -1238,6 +1238,12 @@ public function forceChange() public function validate() { $result = ValidationResult::create(); + // Call DBField::validate() on every DBField + $specs = DataObject::getSchema()->fieldSpecs(static::class); + foreach (array_keys($specs) as $fieldName) { + $dbField = $this->dbObject($fieldName); + $result->combineAnd($dbField->validate()); + } $this->extend('updateValidate', $result); return $result; } diff --git a/src/ORM/FieldType/DBBigInt.php b/src/ORM/FieldType/DBBigInt.php index c92c2da69df..af888e8f0bf 100644 --- a/src/ORM/FieldType/DBBigInt.php +++ b/src/ORM/FieldType/DBBigInt.php @@ -2,18 +2,23 @@ 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. + * Represents a signed 8 byte integer field with a range between -9223372036854775808 and 9223372036854775807 * - * @package framework - * @subpackage model - * @see Int + * 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. */ 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 +29,6 @@ public function requireField(): void '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 c38ddcdfa45..689b33930d3 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\BooleanFieldValidator; use SilverStripe\Forms\CheckboxField; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; @@ -9,15 +10,18 @@ use SilverStripe\Model\ModelData; /** - * Represents a boolean field. + * Represents a boolean field + * Values are stored in the database as tinyint i.e. 1 or 0 */ class DBBoolean extends DBField { - public function __construct(?string $name = null, bool|int $defaultVal = 0) - { - $defaultValue = $defaultVal ? 1 : 0; - $this->setDefaultValue($defaultValue); + private static array $field_validators = [ + BooleanFieldValidator::class, + ]; + public function __construct(?string $name = null, bool|int $defaultVal = false) + { + $this->setDefaultValue($defaultVal); parent::__construct($name); } @@ -28,13 +32,26 @@ public function requireField(): void 'precision' => 1, 'sign' => 'unsigned', 'null' => 'not null', - 'default' => $this->getDefaultValue(), + 'default' => (int) $this->getDefaultValue(), 'arrayValue' => $this->arrayValue ]; $values = ['type' => 'boolean', 'parts' => $parts]; DB::require_field($this->tableName, $this->name, $values); } + public function setDefaultValue(mixed $defaultValue): static + { + $value = (int) $this->convertBooleanLikeValue($defaultValue); + return parent::setDefaultValue($value); + } + + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + $value = $this->convertBooleanLikeValue($value); + parent::setValue($value); + return $this; + } + public function Nice(): string { return ($this->value) ? _t(__CLASS__ . '.YESANSWER', 'Yes') : _t(__CLASS__ . '.NOANSWER', 'No'); @@ -52,7 +69,7 @@ public function saveInto(ModelData $dataObject): void if ($this->value instanceof DBField) { $this->value->saveInto($dataObject); } else { - $dataObject->__set($fieldName, $this->value ? 1 : 0); + $dataObject->__set($fieldName, (bool) $this->value); } } else { $class = static::class; @@ -70,8 +87,8 @@ public function scaffoldSearchField(?string $title = null): ?FormField $anyText = _t(__CLASS__ . '.ANY', 'Any'); $source = [ '' => $anyText, - 1 => _t(__CLASS__ . '.YESANSWER', 'Yes'), - 0 => _t(__CLASS__ . '.NOANSWER', 'No') + '1' => _t(__CLASS__ . '.YESANSWER', 'Yes'), + '0' => _t(__CLASS__ . '.NOANSWER', 'No') ]; return DropdownField::create($this->name, $title, $source) @@ -85,22 +102,35 @@ public function nullValue(): ?int public function prepValueForDB(mixed $value): array|int|null { - if (is_bool($value)) { - return $value ? 1 : 0; - } - if (empty($value)) { - return 0; - } + $bool = $this->convertBooleanLikeValue($value); + // Ensure a tiny int is returned no matter what e.g. value is an + return $bool ? 1 : 0; + } + + /** + * Convert boolean-like values to boolean + * Does not convert non-boolean-like values e.g. array - will be handled by the FieldValidator + */ + private function convertBooleanLikeValue(mixed $value): mixed + { if (is_string($value)) { - switch (strtolower($value ?? '')) { + switch (strtolower($value)) { case 'false': case 'f': - return 0; + case '0': + return false; case 'true': case 't': - return 1; + case '1': + return true; } } - return $value ? 1 : 0; + if ($value === 0) { + return false; + } + if ($value === 1) { + return true; + } + return $value; } } diff --git a/src/ORM/FieldType/DBClassNameVarchar.php b/src/ORM/FieldType/DBClassNameVarchar.php index 2959caba72f..255d47f428b 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\OptionFieldValidator; /** * An alternative to DBClassName that stores the class name as a varchar instead of an enum @@ -24,4 +25,8 @@ class DBClassNameVarchar extends DBVarchar { use DBClassNameTrait; + + private static array $field_validators = [ + OptionFieldValidator::class => ['getEnum'], + ]; } diff --git a/src/ORM/FieldType/DBComposite.php b/src/ORM/FieldType/DBComposite.php index 7060417eadc..ed9a1ea9373 100644 --- a/src/ORM/FieldType/DBComposite.php +++ b/src/ORM/FieldType/DBComposite.php @@ -8,6 +8,7 @@ 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 @@ */ 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 @@ public function setValue(mixed $value, null|array|ModelData $record = null, bool 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/DBCurrency.php b/src/ORM/FieldType/DBCurrency.php index 22d70d398dd..aea5d3b3e39 100644 --- a/src/ORM/FieldType/DBCurrency.php +++ b/src/ORM/FieldType/DBCurrency.php @@ -53,15 +53,14 @@ public function Whole(): string public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static { - $matches = null; - if (is_numeric($value)) { - $this->value = $value; - } elseif (preg_match('/-?\$?[0-9,]+(.[0-9]+)?([Ee][0-9]+)?/', $value ?? '', $matches)) { - $this->value = str_replace(['$', ',', static::config()->get('currency_symbol')], '', $matches[0] ?? ''); - } else { - $this->value = 0; + if (is_string($value)) { + $symbol = static::config()->get('currency_symbol'); + $val = str_replace(['$', ',', $symbol], '', $value); + if (is_numeric($val)) { + $value = (float) $val; + } } - + parent::setValue($value, $record, $markChanged); return $this; } diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index d8a271d7878..39b84e7b01b 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -12,6 +12,7 @@ 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,6 +44,10 @@ 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); diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 287b64690d1..1b3ba7009f2 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -13,6 +13,8 @@ use SilverStripe\Security\Security; use SilverStripe\View\TemplateGlobalProvider; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\DatetimeFieldValidator; /** * 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,6 +51,13 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider */ public const ISO_DATETIME_NORMALISED = 'y-MM-dd\'T\'HH:mm:ss'; + private static array $field_validators = [ + // Remove parent validator + DateFieldValidator::class => null, + // Add datetime validator + DatetimeFieldValidator::class, + ]; + /** * Flag idicating if this field is considered immutable * when this is enabled setting the value of this field will return a new field instance diff --git a/src/ORM/FieldType/DBDecimal.php b/src/ORM/FieldType/DBDecimal.php index deab1bcd05c..917f0079d12 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 @@ */ class DBDecimal extends DBField { + private static array $field_validators = [ + DecimalFieldValidator::class => ['getWholeSize', 'getDecimalSize'], + ]; + /** * Whole number size */ @@ -22,20 +27,15 @@ class DBDecimal extends DBField */ protected int $decimalSize = 2; - /** - * Default value - */ - protected float|int|string $defaultValue = 0; - /** * Create a new Decimal field. */ - public function __construct(?string $name = null, ?int $wholeSize = 9, ?int $decimalSize = 2, float|int $defaultValue = 0) + public function __construct(?string $name = null, ?int $wholeSize = 9, ?int $decimalSize = 2, float|int $defaultValue = 0.0) { $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,12 +50,22 @@ public function Int(): int 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 = [ 'datatype' => 'decimal', 'precision' => "$this->wholeSize,$this->decimalSize", - 'default' => $this->defaultValue, + 'default' => $this->getDefaultValue(), 'arrayValue' => $this->arrayValue ]; @@ -67,6 +77,16 @@ public function requireField(): void DB::require_field($this->tableName, $this->name, $values); } + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + // Cast ints and numeric strings to floats + if (is_int($value) || (is_string($value) && is_numeric($value))) { + $value = (float) $value; + } + parent::setValue($value, $record, $markChanged); + return $this; + } + public function saveInto(ModelData $model): void { $fieldName = $this->name; @@ -91,25 +111,25 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F ->setScale($this->decimalSize); } - public function nullValue(): ?int + public function nullValue(): ?float { - return 0; + return 0.0; } public function prepValueForDB(mixed $value): array|float|int|null { if ($value === true) { - return 1; + return 1.0; } if (empty($value) || !is_numeric($value)) { - return 0; + return 0.0; } if (abs((float) $value - (int) $value) < PHP_FLOAT_EPSILON) { - return (int)$value; + return (int) $value; } - return (float)$value; + return (float) $value; } } diff --git a/src/ORM/FieldType/DBEmail.php b/src/ORM/FieldType/DBEmail.php new file mode 100644 index 00000000000..2c16c8ab6ba --- /dev/null +++ b/src/ORM/FieldType/DBEmail.php @@ -0,0 +1,22 @@ +name, $title); + $field->setMaxLength($this->getSize()); + return $field; + } +} diff --git a/src/ORM/FieldType/DBEnum.php b/src/ORM/FieldType/DBEnum.php index e71e6d17d9c..56409d8fb33 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\OptionFieldValidator; 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 @@ */ class DBEnum extends DBString { + private static array $field_validators = [ + OptionFieldValidator::class => ['getEnum'], + ]; + /** * List of enum values */ @@ -73,14 +79,14 @@ public function __construct( // 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 { diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php index 2752c2ff9dd..8fb3756b2c2 100644 --- a/src/ORM/FieldType/DBField.php +++ b/src/ORM/FieldType/DBField.php @@ -10,6 +10,8 @@ use SilverStripe\ORM\Filters\SearchFilter; use SilverStripe\ORM\Queries\SQLSelect; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\FieldValidationTrait; +use SilverStripe\Core\Validation\FieldValidation\FieldValidationInterface; /** * Single field in the database. @@ -41,8 +43,9 @@ * } * */ -abstract class DBField extends ModelData implements DBIndexable +abstract class DBField extends ModelData implements DBIndexable, FieldValidationInterface { + use FieldValidationTrait; /** * Raw value of this field @@ -99,13 +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. - * @deprecated 5.4.0 Use getDefaultValue() and setDefaultValue() instead */ - protected mixed $defaultVal = null; + private mixed $defaultValue = null; /** * Provide the DBField name and an array of options, e.g. ['index' => true], or ['nullifyEmpty' => false] @@ -122,6 +126,9 @@ public function __construct(?string $name = null, array $options = []) } $this->setOptions($options); } + // Setting value needs to happen below the call to setOptions() in case the default value is set there + $value = $this->getDefaultValue(); + $this->setValue($value); parent::__construct(); } @@ -162,7 +169,7 @@ public static function create_field(string $spec, mixed $value, ?string $name = * * 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." @@ -190,6 +197,17 @@ public function getValue(): mixed return $this->value; } + /** + * Get the value of this field for field validation + * + * Intended to be overridden by subclasses when there is a need to provide + * a different value so that it is suitable for validation e.g. DBComposite + */ + public function getValueForValidation(): mixed + { + return $this->getValue(); + } + /** * Set the value of this field in various formats. * Used by {@link DataObject->getField()}, {@link DataObject->setCastedField()} @@ -215,7 +233,7 @@ public function setValue(mixed $value, null|array|ModelData $record = null, bool */ public function getDefaultValue(): mixed { - return $this->defaultVal; + return $this->defaultValue; } /** @@ -223,7 +241,7 @@ public function getDefaultValue(): mixed */ 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 c4eadc78c8f..e290d594826 100644 --- a/src/ORM/FieldType/DBFloat.php +++ b/src/ORM/FieldType/DBFloat.php @@ -13,9 +13,7 @@ class DBFloat extends DBField { public function __construct(?string $name = null, float|int $defaultVal = 0) { - $defaultValue = is_float($defaultVal) ? $defaultVal : (float) 0; - $this->setDefaultValue($defaultValue); - + $this->setDefaultValue((float) $defaultVal); parent::__construct($name); } @@ -60,7 +58,7 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F public function nullValue(): ?int { - return 0; + return 0.0; } public function prepValueForDB(mixed $value): array|float|int|null diff --git a/src/ORM/FieldType/DBForeignKey.php b/src/ORM/FieldType/DBForeignKey.php index c586b891f08..8f3fc0c5956 100644 --- a/src/ORM/FieldType/DBForeignKey.php +++ b/src/ORM/FieldType/DBForeignKey.php @@ -5,6 +5,7 @@ use SilverStripe\Assets\File; use SilverStripe\Assets\Image; use SilverStripe\Core\Injector\Injector; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; use SilverStripe\Forms\FileHandleField; use SilverStripe\Forms\FormField; use SilverStripe\Forms\SearchableDropdownField; @@ -26,6 +27,10 @@ class DBForeignKey extends DBInt { protected ?DataObject $object; + private static array $field_validators = [ + IntFieldValidator::class => ['getMinValue'], + ]; + /** * Number of related objects to show in a scaffolded searchable dropdown field before it * switches to using lazyloading. @@ -63,6 +68,16 @@ public function setValue(mixed $value, null|array|ModelData $record = null, bool 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); } + + public function getMinValue(): int + { + return 0; + } } diff --git a/src/ORM/FieldType/DBInt.php b/src/ORM/FieldType/DBInt.php index ca12b2ead1c..51d0a3a2f56 100644 --- a/src/ORM/FieldType/DBInt.php +++ b/src/ORM/FieldType/DBInt.php @@ -2,33 +2,38 @@ 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, which has a range between -2147483648 and 2147483647. */ class DBInt extends DBField { + private static array $field_validators = [ + IntFieldValidator::class + ]; + public function __construct(?string $name = null, int $defaultVal = 0) { - $defaultValue = is_int($defaultVal) ? $defaultVal : 0; - $this->setDefaultValue($defaultValue); - + $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 setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static { - return (int) $this->value; + 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; } /** diff --git a/src/ORM/FieldType/DBIp.php b/src/ORM/FieldType/DBIp.php new file mode 100644 index 00000000000..ecd28a0dd7c --- /dev/null +++ b/src/ORM/FieldType/DBIp.php @@ -0,0 +1,13 @@ + null, + OptionFieldValidator::class => null, + // enable multi enum field validator + MultiOptionFieldValidator::class => ['getEnum'], + ]; + public function __construct($name = null, $enum = null, $default = null) { // MultiEnum needs to take care of its own defaults @@ -34,6 +45,15 @@ public function __construct($name = null, $enum = null, $default = null) } } + public function getValueForValidation(): iterable + { + $value = parent::getValueForValidation(); + if (is_iterable($value)) { + return $value; + } + return explode(',', (string) $value); + } + public function requireField(): void { $charset = Config::inst()->get(MySQLDatabase::class, 'charset'); diff --git a/src/ORM/FieldType/DBPercentage.php b/src/ORM/FieldType/DBPercentage.php index 3ceab526522..f6ea2941045 100644 --- a/src/ORM/FieldType/DBPercentage.php +++ b/src/ORM/FieldType/DBPercentage.php @@ -3,6 +3,7 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\DecimalFieldValidator; /** * Represents a decimal field from 0-1 containing a percentage value. @@ -17,6 +18,10 @@ */ class DBPercentage extends DBDecimal { + private static array $field_validators = [ + DecimalFieldValidator::class => ['getWholeSize', 'getDecimalSize', 'getMinValue', 'getMaxValue'], + ]; + /** * Create a new Decimal field. */ @@ -29,6 +34,16 @@ public function __construct(?string $name = null, int $precision = 4) parent::__construct($name, $precision + 1, $precision); } + public function getMinValue(): float + { + return 0.0; + } + + public function getMaxValue(): float + { + return 1.0; + } + /** * Returns the number, expressed as a percentage. For example, “36.30%” */ diff --git a/src/ORM/FieldType/DBPolymorphicForeignKey.php b/src/ORM/FieldType/DBPolymorphicForeignKey.php index 62b7926062b..7aac039c101 100644 --- a/src/ORM/FieldType/DBPolymorphicForeignKey.php +++ b/src/ORM/FieldType/DBPolymorphicForeignKey.php @@ -5,6 +5,7 @@ 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 diff --git a/src/ORM/FieldType/DBString.php b/src/ORM/FieldType/DBString.php index 99d597aa9c5..ae98785f3f2 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); } diff --git a/src/ORM/FieldType/DBTime.php b/src/ORM/FieldType/DBTime.php index fac285be19c..5b44cc6fac3 100644 --- a/src/ORM/FieldType/DBTime.php +++ b/src/ORM/FieldType/DBTime.php @@ -11,6 +11,7 @@ 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,9 +27,14 @@ 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); diff --git a/src/ORM/FieldType/DBUrl.php b/src/ORM/FieldType/DBUrl.php new file mode 100644 index 00000000000..ab9435c6555 --- /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 3081ad34be0..b0cbbc017e4 100644 --- a/src/ORM/FieldType/DBVarchar.php +++ b/src/ORM/FieldType/DBVarchar.php @@ -8,6 +8,7 @@ use SilverStripe\Forms\TextField; use SilverStripe\ORM\Connect\MySQLDatabase; use SilverStripe\ORM\DB; +use SilverStripe\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 @@ */ 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 04618cae339..aa1bad85923 100644 --- a/src/ORM/FieldType/DBYear.php +++ b/src/ORM/FieldType/DBYear.php @@ -5,12 +5,24 @@ use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; use SilverStripe\ORM\DB; +use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\YearFieldValidator; /** - * Represents a single year field. + * Represents a single year field + * This field is only intended to be used with a MySQL database and the year datatype */ class DBYear extends DBField { + // MySQL year datatype supports years between 1901 and 2155 + // https://dev.mysql.com/doc/refman/8.0/en/year.html + public const MIN_YEAR = 1901; + public const MAX_YEAR = 2155; + + private static $field_validators = [ + YearFieldValidator::class => ['getMinYear', 'getMaxYear'], + ]; + public function requireField(): void { $parts = ['datatype' => 'year', 'precision' => 4, 'arrayValue' => $this->arrayValue]; @@ -25,11 +37,51 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F 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 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 +89,10 @@ public function scaffoldFormField(?string $title = null, array $params = []): ?F 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/src/Security/Member.php b/src/Security/Member.php index 94ea15ccfda..8746ecca084 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -444,7 +444,6 @@ public function isPasswordExpired(): bool if (!$this->PasswordExpiry) { return false; } - return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry ?? ''); } diff --git a/src/Security/Security.php b/src/Security/Security.php index b3539398dfb..ee69e1b1cfb 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -1063,6 +1063,12 @@ public static function encrypt_password($password, $salt = null, $algorithm = nu // New salts will only need to be generated if the password is hashed for the first time $salt = ($salt) ? $salt : $encryptor->salt($password); + // PasswordEncryptors may return false, though the Member.db ['Salt'] field is + // a Varchar so we need to ensure it's returned as a string + if ($salt === false) { + $salt = ''; + } + return [ 'password' => $encryptor->encrypt($password, $salt, $member), 'salt' => $salt, diff --git a/tests/behat/src/CmsUiContext.php b/tests/behat/src/CmsUiContext.php index 7bc4a3bfcd8..346e8efe5e3 100644 --- a/tests/behat/src/CmsUiContext.php +++ b/tests/behat/src/CmsUiContext.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\Assert; use SilverStripe\BehatExtension\Context\MainContextAwareTrait; use SilverStripe\BehatExtension\Utility\StepHelper; +use Facebook\WebDriver\Exception\ElementNotInteractableException; /** * CmsUiContext diff --git a/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php new file mode 100644 index 00000000000..97be2f5c3b2 --- /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, + ], + 'valid-null' => [ + 'value' => null, + '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-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/BooleanFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/BooleanFieldValidatorTest.php new file mode 100644 index 00000000000..a91a3e42bb6 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/BooleanFieldValidatorTest.php @@ -0,0 +1,72 @@ + [ + 'value' => true, + 'expected' => true, + ], + 'valid-false' => [ + 'value' => false, + 'expected' => true, + ], + 'valid-null' => [ + 'value' => null, + 'expected' => true, + ], + 'invalid-int-1' => [ + 'value' => 1, + 'expected' => false, + ], + 'invalid-int-0' => [ + 'value' => 0, + '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-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 BooleanFieldValidator('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 00000000000..b8c12004fba --- /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 00000000000..bfee84b68da --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/DateFieldValidatorTest.php @@ -0,0 +1,52 @@ + [ + 'value' => '2020-09-15', + 'expected' => true, + ], + 'valid-blank-string' => [ + 'value' => '', + 'expected' => true, + ], + 'valid-null' => [ + 'value' => null, + '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, + ], + ]; + } + + #[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 00000000000..0ad45591689 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php @@ -0,0 +1,52 @@ + [ + 'value' => '2020-09-15 13:34:56', + 'expected' => true, + ], + 'valid-null' => [ + 'value' => null, + '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, + ], + ]; + } + + #[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 00000000000..1a8b8e27ca6 --- /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, + ], + 'valid-null' => [ + 'value' => null, + '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-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 00000000000..f773d7d736d --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php @@ -0,0 +1,37 @@ + [ + 'value' => 'test@example.com', + 'expected' => true, + ], + 'valid-null' => [ + 'value' => null, + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'fish', + '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/IntFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php new file mode 100644 index 00000000000..b0989faae2d --- /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, + ], + 'valid-null' => [ + 'value' => null, + '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-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 00000000000..9b845d68d0e --- /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, + ], + 'valid-null' => [ + 'value' => null, + 'expected' => true, + ], + 'invalid' => [ + 'value' => '12345', + '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 00000000000..0ebe075aa08 --- /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, + ], + 'valid-null' => [ + 'value' => null, + '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, + ], + ]; + } + + #[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/MultiOptionFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/MultiOptionFieldValidatorTest.php new file mode 100644 index 00000000000..0171f8bcccf --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/MultiOptionFieldValidatorTest.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 MultiOptionFieldValidator('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 00000000000..daa2f084648 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php @@ -0,0 +1,170 @@ + [ + '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, + ], + 'valid-null' => [ + 'value' => null, + 'expected' => true, + ], + 'invalid-string' => [ + 'value' => '123', + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123], + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidateType')] + public function testValidateType(mixed $value, bool $expected): void + { + $validator = new NumericFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } + + public static function provideValidate(): array + { + return [ + 'valid' => [ + 'value' => 10, + 'minValue' => null, + 'maxValue' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-min' => [ + 'value' => 15, + 'minValue' => 10, + 'maxValue' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-min-equal' => [ + 'value' => 10, + 'minValue' => 10, + 'maxValue' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-max' => [ + 'value' => 5, + 'minValue' => null, + 'maxValue' => 10, + 'exception' => false, + 'expected' => true, + ], + 'valid-max-equal' => [ + 'value' => 10, + 'minValue' => null, + 'maxValue' => 10, + 'exception' => false, + 'expected' => true, + ], + 'valid-min-max-between' => [ + 'value' => 15, + 'minValue' => 10, + 'maxValue' => 20, + 'exception' => false, + 'expected' => true, + ], + 'valid-min-max-equal' => [ + 'value' => 10, + 'minValue' => 10, + 'maxValue' => 10, + 'exception' => false, + 'expected' => true, + ], + 'exception-min-above-max' => [ + 'value' => 15, + 'minValue' => 20, + 'maxValue' => 10, + 'exception' => true, + 'expected' => false, + ], + 'invalid-below-min' => [ + 'value' => 5, + 'minValue' => 10, + 'maxValue' => 20, + 'exception' => false, + 'expected' => false, + ], + 'invalid-above-max' => [ + 'value' => 25, + 'minValue' => 10, + 'maxValue' => 20, + 'exception' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(int $value, ?int $minValue, ?int $maxValue, bool $exception, bool $expected): void + { + if ($exception) { + $this->expectException(InvalidArgumentException::class); + } + $validator = new NumericFieldValidator('MyField', $value, $minValue, $maxValue); + $result = $validator->validate(); + if (!$exception) { + $this->assertSame($expected, $result->isValid()); + } + } +} diff --git a/tests/php/Core/Validation/FieldValidation/OptionFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/OptionFieldValidatorTest.php new file mode 100644 index 00000000000..959b7617a6f --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/OptionFieldValidatorTest.php @@ -0,0 +1,54 @@ + [ + 'value' => 'cat', + 'allowedValues' => ['cat', 'dog'], + 'expected' => true, + ], + 'valid-int' => [ + 'value' => 123, + 'allowedValues' => [123, 456], + 'expected' => true, + ], + 'valid-none' => [ + 'value' => '', + 'allowedValues' => ['cat', 'dog'], + 'expected' => true, + ], + 'valid-null' => [ + 'value' => null, + 'allowedValues' => ['cat', 'dog'], + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'fish', + '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 OptionFieldValidator('MyField', $value, $allowedValues); + $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 00000000000..c447b5b6ff4 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php @@ -0,0 +1,163 @@ + [ + 'value' => 'fish', + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-blank' => [ + 'value' => '', + 'minLength' => null, + '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, + ], + 'valid-null' => [ + 'value' => null, + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => true, + ], + 'exception-negative-min' => [ + 'value' => 'fish', + 'minLength' => -1, + 'maxLength' => null, + 'exception' => true, + 'expected' => false, + ], + 'exception-negative-max' => [ + 'value' => 'fish', + 'minLength' => null, + 'maxLength' => -1, + 'exception' => true, + 'expected' => false, + ], + 'exception-max-below-min' => [ + 'value' => 'fish', + 'minLength' => 20, + 'maxLength' => 10, + '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-blank-with-min' => [ + '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-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 00000000000..fc39f818ce8 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php @@ -0,0 +1,48 @@ + [ + 'value' => '13:34:56', + 'expected' => true, + ], + 'valid-null' => [ + 'value' => null, + '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, + ], + ]; + } + + #[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 00000000000..b3de3ceceea --- /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, + ], + 'valid-null' => [ + 'value' => null, + 'expected' => true, + ], + 'invalid-ftp' => [ + 'value' => 'ftp://www.example.com', + 'expected' => false, + ], + 'invalid-no-scheme' => [ + 'value' => 'www.example.com', + '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/Core/Validation/FieldValidation/YearFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/YearFieldValidatorTest.php new file mode 100644 index 00000000000..13be67b175e --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/YearFieldValidatorTest.php @@ -0,0 +1,47 @@ + [ + 'value' => 2021, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'expected' => true, + ], + 'valid-null' => [ + 'value' => null, + 'expected' => true, + ], + 'invalid-out-of-range-low' => [ + 'value' => 1850, + 'expected' => false, + ], + 'invalid-out-of-range-high' => [ + 'value' => 3000, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new YearFieldValidator('MyField', $value, DBYear::MIN_YEAR, DBYear::MAX_YEAR); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Forms/FormFieldTest.php b/tests/php/Forms/FormFieldTest.php index 95ad6f7f580..7c75ddb3162 100644 --- a/tests/php/Forms/FormFieldTest.php +++ b/tests/php/Forms/FormFieldTest.php @@ -39,6 +39,7 @@ use SilverStripe\Forms\SearchableMultiDropdownField; use SilverStripe\Forms\SearchableDropdownField; use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\ORM\FieldType\DBInt; class FormFieldTest extends SapphireTest { diff --git a/tests/php/ORM/DBBooleanTest.php b/tests/php/ORM/DBBooleanTest.php new file mode 100644 index 00000000000..1d52bf6b32e --- /dev/null +++ b/tests/php/ORM/DBBooleanTest.php @@ -0,0 +1,98 @@ +assertSame(false, $field->getValue()); + } + + public static function provideSetValue(): array + { + return [ + 'true' => [ + 'value' => true, + 'expected' => true, + ], + 'false' => [ + 'value' => false, + 'expected' => false, + ], + '1-int' => [ + 'value' => 1, + 'expected' => true, + ], + '1-string' => [ + 'value' => '1', + 'expected' => true, + ], + '0-int' => [ + 'value' => 0, + 'expected' => false, + ], + '0-string' => [ + 'value' => '0', + 'expected' => false, + ], + 't' => [ + 'value' => 't', + 'expected' => true, + ], + 'f' => [ + 'value' => 'f', + 'expected' => false, + ], + 'T' => [ + 'value' => 'T', + 'expected' => true, + ], + 'F' => [ + 'value' => 'F', + 'expected' => false, + ], + 'true-string' => [ + 'value' => 'true', + 'expected' => true, + ], + 'false-string' => [ + 'value' => 'false', + 'expected' => false, + ], + '2-int' => [ + 'value' => 2, + 'expected' => 2, + ], + '0.0' => [ + 'value' => 0.0, + 'expected' => 0.0, + ], + '1.0' => [ + 'value' => 1.0, + 'expected' => 1.0, + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + 'array' => [ + 'value' => [], + 'expected' => [], + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBBoolean('MyField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } +} diff --git a/tests/php/ORM/DBCompositeTest.php b/tests/php/ORM/DBCompositeTest.php index 6f096628b71..c8f221cf120 100644 --- a/tests/php/ORM/DBCompositeTest.php +++ b/tests/php/ORM/DBCompositeTest.php @@ -6,6 +6,9 @@ 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 @@ public function testWriteToManipuationIsCalledWhenWritingDataObject() // $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/DBCurrencyTest.php b/tests/php/ORM/DBCurrencyTest.php index 7a16091261c..c2df4cfafa2 100644 --- a/tests/php/ORM/DBCurrencyTest.php +++ b/tests/php/ORM/DBCurrencyTest.php @@ -5,6 +5,7 @@ use SilverStripe\Forms\CurrencyField; use SilverStripe\ORM\FieldType\DBCurrency; use SilverStripe\Dev\SapphireTest; +use PHPUnit\Framework\Attributes\DataProvider; class DBCurrencyTest extends SapphireTest { @@ -15,11 +16,6 @@ public function testNiceFormatting() // Test basic operation '$50.00' => ['$50.00', '$50'], - // Test removal of junk text - 'this is -50.29 dollars' => ['($50.29)', '($50)'], - 'this is -50.79 dollars' => ['($50.79)', '($51)'], - 'this is 50.79 dollars' => ['$50.79', '$51'], - // Test negative numbers '-1000' => ['($1,000.00)','($1,000)'], '-$2,000' => ['($2,000.00)', '($2,000)'], @@ -30,9 +26,6 @@ public function testNiceFormatting() // Test scientific notation '5.68434188608E-14' => ['$0.00', '$0'], '5.68434188608E7' => ['$56,843,418.86', '$56,843,419'], - "Sometimes Es are still bad: 51 dollars, even though they\'re used in scientific notation" - => ['$51.00', '$51'], - "What about 5.68434188608E7 in the middle of a string" => ['$56,843,418.86', '$56,843,419'], ]; foreach ($tests as $value => $niceValues) { @@ -51,4 +44,75 @@ public function testScaffoldedField() $this->assertInstanceOf(CurrencyField::class, $scaffoldedField); } + + public static function provideSetValue(): array + { + // Most test cases covered by DBCurrencyTest, only testing a subset here + return [ + 'currency' => [ + 'value' => '$1.23', + 'expected' => 1.23, + ], + 'negative-currency' => [ + 'value' => "-$1.23", + 'expected' => -1.23, + ], + 'scientific-1' => [ + 'value' => 5.68434188608E-14, + 'expected' => 5.68434188608E-14, + ], + 'scientific-2' => [ + 'value' => 5.68434188608E7, + 'expected' => 56843418.8608, + ], + 'scientific-1-string' => [ + 'value' => '5.68434188608E-14', + 'expected' => 5.68434188608E-14, + ], + 'scientific-2-string' => [ + 'value' => '5.68434188608E7', + 'expected' => 56843418.8608, + ], + 'int' => [ + 'value' => 1, + 'expected' => 1.0, + ], + 'string-int' => [ + 'value' => "1", + 'expected' => 1.0, + ], + 'string-float' => [ + 'value' => '1.2', + 'expected' => 1.2, + ], + 'value-in-string' => [ + 'value' => 'this is 50.29 dollars', + 'expected' => 'this is 50.29 dollars', + ], + 'scientific-value-in-string' => [ + 'value' => '5.68434188608E7 a string', + 'expected' => '5.68434188608E7 a string', + ], + 'value-in-brackets' => [ + 'value' => '(100)', + 'expected' => '(100)', + ], + 'non-numeric' => [ + 'value' => 'fish', + 'expected' => 'fish', + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBCurrency('MyField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } } diff --git a/tests/php/ORM/DBDecimalTest.php b/tests/php/ORM/DBDecimalTest.php new file mode 100644 index 00000000000..5a7b0068a04 --- /dev/null +++ b/tests/php/ORM/DBDecimalTest.php @@ -0,0 +1,87 @@ +assertSame(0.0, $field->getValue()); + } + + public static function provideSetValue(): array + { + return [ + 'float' => [ + 'value' => 9.123, + 'expected' => 9.123, + ], + 'negative-float' => [ + 'value' => -9.123, + 'expected' => -9.123, + ], + 'string-float' => [ + 'value' => '9.123', + 'expected' => 9.123, + ], + 'string-negative-float' => [ + 'value' => '-9.123', + 'expected' => -9.123, + ], + 'zero' => [ + 'value' => 0, + 'expected' => 0.0, + ], + 'int' => [ + 'value' => 3, + 'expected' => 3.0, + ], + 'negative-int' => [ + 'value' => -3, + 'expected' => -3.0, + ], + 'string-int' => [ + 'value' => '3', + 'expected' => 3.0, + ], + 'negative-string-int' => [ + 'value' => '-3', + 'expected' => -3.0, + ], + 'string' => [ + 'value' => 'fish', + 'expected' => 'fish', + ], + 'array' => [ + 'value' => [], + 'expected' => [], + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + 'true' => [ + 'value' => true, + 'expected' => true, + ], + 'false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBDecimal('MyField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } +} diff --git a/tests/php/ORM/DBEnumTest.php b/tests/php/ORM/DBEnumTest.php index a44fbc57f51..9190617e400 100644 --- a/tests/php/ORM/DBEnumTest.php +++ b/tests/php/ORM/DBEnumTest.php @@ -118,7 +118,7 @@ public function testObsoleteValues() $obj2 = new FieldType\DBEnumTestObject(); $obj2->Colour = 'Purple'; - $obj2->write(); + $obj2->write(skipValidation: true); $this->assertEquals( ['Purple', 'Red'], @@ -141,4 +141,56 @@ public function testObsoleteValues() $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' => '', + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + ]; + } + + #[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()); + } + + public function testSaveDefaultValue() + { + $obj = new FieldType\DBEnumTestObject(); + $id = $obj->write(); + // Fetch the object from the database + $obj = FieldType\DBEnumTestObject::get()->byID($id); + $this->assertEquals('Red', $obj->Colour); + $this->assertEquals('Blue', $obj->ColourWithDefault); + // Set value to null and save + $obj->Colour = null; + $obj->ColourWithDefault = null; + $obj->write(); + // Fetch the object from the database + $obj = FieldType\DBEnumTestObject::get()->byID($id); + $this->assertEquals(null, $obj->Colour); + $this->assertEquals(null, $obj->ColourWithDefault); + } } diff --git a/tests/php/ORM/DBFieldTest.php b/tests/php/ORM/DBFieldTest.php index b64cebf1bfe..76936231ac6 100644 --- a/tests/php/ORM/DBFieldTest.php +++ b/tests/php/ORM/DBFieldTest.php @@ -2,7 +2,7 @@ namespace SilverStripe\ORM\Tests; -use SilverStripe\Assets\Image; +use Exception; use SilverStripe\ORM\FieldType\DBBigInt; use SilverStripe\ORM\FieldType\DBBoolean; use SilverStripe\ORM\FieldType\DBCurrency; @@ -30,6 +30,32 @@ 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\BooleanFieldValidator; +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\OptionFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\IpFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\LocaleFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\MultiOptionFieldValidator; +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\ORM\FieldType\DBClassNameVarchar; /** * Tests for DBField objects. @@ -392,4 +418,165 @@ public function testSaveIntoRespectsSetters() $this->assertEquals('new value', $obj->getField('MyTestField')); } + + public function testDefaultValues(): void + { + $expectedBaseDefault = null; + $expectedDefaults = [ + DBBoolean::class => false, + DBDecimal::class => 0.0, + DBInt::class => 0, + DBFloat::class => 0.0, + ]; + $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; + } + $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); + $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); + } + + public function testFieldValidatorConfig(): void + { + $expectedFieldValidators = [ + DBBigInt::class => [ + BigIntFieldValidator::class, + ], + DBBoolean::class => [ + BooleanFieldValidator::class, + ], + DBClassName::class => [ + StringFieldValidator::class, + OptionFieldValidator::class, + ], + DBClassNameVarchar::class => [ + StringFieldValidator::class, + OptionFieldValidator::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, + OptionFieldValidator::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 => [ + MultiOptionFieldValidator::class, + ], + DBPercentage::class => [ + DecimalFieldValidator::class, + ], + DBPolymorphicForeignKey::class => [ + CompositeFieldValidator::class, + ], + DBPolymorphicRelationAwareForeignKey::class => [ + CompositeFieldValidator::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 00000000000..d16f973b38f --- /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 554d80232a1..a2d9d249c7b 100644 --- a/tests/php/ORM/DBIntTest.php +++ b/tests/php/ORM/DBIntTest.php @@ -4,15 +4,82 @@ 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, + ], + 'float' => [ + 'value' => 3.5, + 'expected' => 3.5, + ], + '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 00000000000..813502654d8 --- /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 81a4d381660..d9513d47a86 100644 --- a/tests/php/ORM/DBStringTest.php +++ b/tests/php/ORM/DBStringTest.php @@ -7,6 +7,7 @@ use SilverStripe\ORM\FieldType\DBString; use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\Tests\DBStringTest\MyStringField; +use PHPUnit\Framework\Attributes\DataProvider; class DBStringTest extends SapphireTest { diff --git a/tests/php/ORM/DBYearTest.php b/tests/php/ORM/DBYearTest.php index 79632bae35f..7107451854e 100755 --- a/tests/php/ORM/DBYearTest.php +++ b/tests/php/ORM/DBYearTest.php @@ -5,6 +5,7 @@ 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 @@ public function testScaffoldFormFieldFirst() $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 @@ public function testScaffoldFormFieldLast() $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()); + } } diff --git a/tests/php/ORM/DecimalTest.php b/tests/php/ORM/DecimalTest.php index 0705da1094f..0fab0a8adc9 100644 --- a/tests/php/ORM/DecimalTest.php +++ b/tests/php/ORM/DecimalTest.php @@ -24,22 +24,23 @@ protected function setUp(): void { parent::setUp(); $this->testDataObject = $this->objFromFixture(DecimalTest\TestObject::class, 'test-dataobject'); + $x=1; } public function testDefaultValue() { - $this->assertEquals( + $this->assertSame( + 0.0, $this->testDataObject->MyDecimal1, - 0, - 'Database default for Decimal type is 0' + 'Database default for Decimal type is 0.0' ); } public function testSpecifiedDefaultValue() { - $this->assertEquals( - $this->testDataObject->MyDecimal2, + $this->assertSame( 2.5, + $this->testDataObject->MyDecimal2, 'Default value for Decimal type is set to 2.5' ); } @@ -52,37 +53,37 @@ public function testInvalidSpecifiedDefaultValue() public function testSpecifiedDefaultValueInDefaultsArray() { - $this->assertEquals( + $this->assertSame( $this->testDataObject->MyDecimal4, - 4, + 4.0, 'Default value for Decimal type is set to 4' ); } public function testLongValueStoredCorrectly() { - $this->assertEquals( - $this->testDataObject->MyDecimal5, + $this->assertSame( 1.0, + $this->testDataObject->MyDecimal5, 'Long default long decimal value is rounded correctly' ); $this->assertEqualsWithDelta( - $this->testDataObject->MyDecimal5, 0.99999999999999999999, + $this->testDataObject->MyDecimal5, PHP_FLOAT_EPSILON, 'Long default long decimal value is correct within float epsilon' ); - $this->assertEquals( - $this->testDataObject->MyDecimal6, + $this->assertSame( 8.0, + $this->testDataObject->MyDecimal6, 'Long decimal value with a default value is rounded correctly' ); $this->assertEqualsWithDelta( - $this->testDataObject->MyDecimal6, 7.99999999999999999999, + $this->testDataObject->MyDecimal6, PHP_FLOAT_EPSILON, 'Long decimal value is within epsilon if longer than allowed number of float digits' ); diff --git a/tests/php/ORM/FieldType/DBEnumTestObject.php b/tests/php/ORM/FieldType/DBEnumTestObject.php index b9c70d71c96..6d2723c9588 100644 --- a/tests/php/ORM/FieldType/DBEnumTestObject.php +++ b/tests/php/ORM/FieldType/DBEnumTestObject.php @@ -11,5 +11,6 @@ class DBEnumTestObject extends DataObject private static $db = [ 'Colour' => 'Enum("Red,Blue,Green")', + 'ColourWithDefault' => 'Enum("Red,Blue,Green","Blue")', ]; }