From 13530289746979264ff030ae5aecac2830c0a3bc Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Thu, 17 Oct 2024 17:41:36 +1300 Subject: [PATCH] NEW Validate DBFields --- _config/model.yml | 6 + composer.json | 1 + src/Core/Validation/ConstraintValidator.php | 4 +- .../AbstractSymfonyFieldValidator.php | 48 +++++ .../FieldValidation/BigIntFieldValidator.php | 36 ++++ .../FieldValidation/BooleanFieldValidator.php | 23 ++ .../CompositeFieldValidator.php | 33 +++ .../FieldValidation/DateFieldValidator.php | 40 ++++ .../DatetimeFieldValidator.php | 23 ++ .../FieldValidation/DecimalFieldValidator.php | 65 ++++++ .../FieldValidation/EmailFieldValidator.php | 19 ++ .../FieldValidation/EnumFieldValidator.php | 31 +++ .../FieldValidationInterface.php | 14 ++ .../FieldValidation/FieldValidator.php | 39 ++++ .../FieldValidation/FieldValidatorsTrait.php | 122 +++++++++++ .../FieldValidation/IntFieldValidator.php | 46 ++++ .../FieldValidation/IpFieldValidator.php | 30 +++ .../FieldValidation/LocaleFieldValidator.php | 22 ++ .../MultiEnumFieldValidator.php | 31 +++ .../FieldValidation/NumericFieldValidator.php | 56 +++++ .../FieldValidation/StringFieldValidator.php | 65 ++++++ .../FieldValidation/TimeFieldValidator.php | 23 ++ .../FieldValidation/UrlFieldValidator.php | 19 ++ .../FieldValidation/YearFieldValidator.php | 42 ++++ src/Core/Validation/ValidationInterface.php | 10 + src/Core/Validation/ValidationResult.php | 43 +++- src/Forms/CompositeField.php | 4 +- src/Forms/FieldGroup.php | 2 +- src/Forms/FormField.php | 49 ++++- src/Forms/SelectionGroup_Item.php | 2 +- src/ORM/DataObject.php | 9 + src/ORM/FieldType/DBBigInt.php | 13 +- src/ORM/FieldType/DBBoolean.php | 62 ++++-- src/ORM/FieldType/DBClassNameVarchar.php | 5 + src/ORM/FieldType/DBComposite.php | 14 ++ src/ORM/FieldType/DBCurrency.php | 21 +- src/ORM/FieldType/DBDate.php | 72 +++---- 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 | 29 ++- src/ORM/FieldType/DBFloat.php | 6 +- src/ORM/FieldType/DBForeignKey.php | 5 + src/ORM/FieldType/DBInt.php | 35 ++- 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 | 6 + src/ORM/FieldType/DBString.php | 10 +- src/ORM/FieldType/DBTime.php | 32 +-- src/ORM/FieldType/DBUrl.php | 22 ++ src/ORM/FieldType/DBVarchar.php | 5 + src/ORM/FieldType/DBYear.php | 59 ++++- src/Security/Member.php | 6 +- .../BigIntFieldValidatorTest.php | 75 +++++++ .../BooleanFieldValidatorTest.php | 72 +++++++ .../CompositeFieldValidatorTest.php | 97 +++++++++ .../DateFieldValidatorTest.php | 52 +++++ .../DatetimeFieldValidatorTest.php | 52 +++++ .../DecimalFieldValidatorTest.php | 138 ++++++++++++ .../EmailFieldValidatorTest.php | 37 ++++ .../EnumFieldValidatorTest.php | 54 +++++ .../FieldValidation/IntFieldValidatorTest.php | 76 +++++++ .../FieldValidation/IpFieldValidatorTest.php | 45 ++++ .../LocaleFieldValidatorTest.php | 61 ++++++ .../MultiEnumFieldValidatorTest.php | 84 ++++++++ .../NumericFieldValidatorTest.php | 80 +++++++ .../StringFieldValidatorTest.php | 149 +++++++++++++ .../TimeFieldValidatorTest.php | 48 +++++ .../FieldValidation/UrlFieldValidatorTest.php | 45 ++++ .../YearFieldValidatorTest.php | 43 ++++ 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/DBDateTest.php | 100 +++++---- tests/php/ORM/DBDatetimeTest.php | 64 ++++-- tests/php/ORM/DBDecimalTest.php | 87 ++++++++ tests/php/ORM/DBEnumTest.php | 54 ++++- tests/php/ORM/DBFieldTest.php | 203 +++++++++++++++++- 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/DBTimeTest.php | 87 ++++++-- tests/php/ORM/DBYearTest.php | 103 ++++++++- tests/php/ORM/DecimalTest.php | 27 +-- tests/php/ORM/FieldType/DBEnumTestObject.php | 1 + 90 files changed, 3524 insertions(+), 270 deletions(-) create mode 100644 src/Core/Validation/FieldValidation/AbstractSymfonyFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/BigIntFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/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/EnumFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidationInterface.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/FieldValidatorsTrait.php create mode 100644 src/Core/Validation/FieldValidation/IntFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/IpFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/LocaleFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/MultiEnumFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/NumericFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/StringFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/TimeFieldValidator.php create mode 100644 src/Core/Validation/FieldValidation/UrlFieldValidator.php create mode 100644 src/Core/Validation/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/EnumFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php create mode 100644 tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php create mode 100644 tests/php/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 4046feddb..ca4d8e193 100644 --- a/_config/model.yml +++ b/_config/model.yml @@ -20,6 +20,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBDecimal Double: class: SilverStripe\ORM\FieldType\DBDouble + Email: + class: SilverStripe\ORM\FieldType\DBEmail Enum: class: SilverStripe\ORM\FieldType\DBEnum Float: @@ -36,6 +38,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBHTMLVarchar Int: class: SilverStripe\ORM\FieldType\DBInt + IP: + class: SilverStripe\ORM\FieldType\DBIp BigInt: class: SilverStripe\ORM\FieldType\DBBigInt Locale: @@ -58,6 +62,8 @@ SilverStripe\Core\Injector\Injector: class: SilverStripe\ORM\FieldType\DBText Time: class: SilverStripe\ORM\FieldType\DBTime + URL: + class: SilverStripe\ORM\FieldType\DBUrl Varchar: class: SilverStripe\ORM\FieldType\DBVarchar Year: diff --git a/composer.json b/composer.json index a88e48e38..0f2e499e1 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/ConstraintValidator.php b/src/Core/Validation/ConstraintValidator.php index 9a6568998..941a18a8d 100644 --- a/src/Core/Validation/ConstraintValidator.php +++ b/src/Core/Validation/ConstraintValidator.php @@ -35,9 +35,9 @@ class ConstraintValidator /** @var ConstraintViolationInterface $violation */ foreach ($violations as $violation) { if ($fieldName) { - $result->addFieldError($fieldName, $violation->getMessage()); + $result->addFieldError($fieldName, $violation->getMessage(), value: $value); } else { - $result->addError($violation->getMessage()); + $result->addError($violation->getMessage(), value: $value); } } diff --git a/src/Core/Validation/FieldValidation/AbstractSymfonyFieldValidator.php b/src/Core/Validation/FieldValidation/AbstractSymfonyFieldValidator.php new file mode 100644 index 000000000..195bd0eb5 --- /dev/null +++ b/src/Core/Validation/FieldValidation/AbstractSymfonyFieldValidator.php @@ -0,0 +1,48 @@ +isValid()) { + return $result; + } + $constraintClass = $this->getConstraintClass(); + $args = [ + ...$this->getContraintNamedArgs(), + 'message' => $this->getMessage(), + ]; + $constraint = new $constraintClass(...$args); + $validationResult = ConstraintValidator::validate($this->value, $constraint, $this->name); + return $result->combineAnd($validationResult); + } + + /** + * The symfony constraint class to use + */ + abstract protected function getConstraintClass(): string; + + /** + * The named args to pass to the constraint + * Defined named args as assoc array keys + */ + protected function getContraintNamedArgs(): array + { + return []; + } + + /** + * The message to use when the value is invalid + */ + abstract protected function getMessage(): string; +} diff --git a/src/Core/Validation/FieldValidation/BigIntFieldValidator.php b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php new file mode 100644 index 000000000..7a77b0daf --- /dev/null +++ b/src/Core/Validation/FieldValidation/BigIntFieldValidator.php @@ -0,0 +1,36 @@ +value !== true && $this->value !== false) { + $message = _t(__CLASS__ . '.INVALID', 'Invalid value'); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/CompositeFieldValidator.php b/src/Core/Validation/FieldValidation/CompositeFieldValidator.php new file mode 100644 index 000000000..60a83288c --- /dev/null +++ b/src/Core/Validation/FieldValidation/CompositeFieldValidator.php @@ -0,0 +1,33 @@ +value as $child) { + $result->combineAnd($child->validate()); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/DateFieldValidator.php b/src/Core/Validation/FieldValidation/DateFieldValidator.php new file mode 100644 index 000000000..798991330 --- /dev/null +++ b/src/Core/Validation/FieldValidation/DateFieldValidator.php @@ -0,0 +1,40 @@ +value === '') { + return $result; + } + // Not using symfony/validator because it was allowing d-m-Y format strings + $date = date_parse_from_format($this->getFormat(), $this->value ?? ''); + if ($date === false || $date['error_count'] > 0 || $date['warning_count'] > 0) { + $result->addFieldError($this->name, $this->getMessage(), value: $this->value); + } + return $result; + } + + protected function getFormat(): string + { + return 'Y-m-d'; + } + + protected function getMessage(): string + { + return _t(__CLASS__ . '.INVALID', 'Invalid date'); + } +} diff --git a/src/Core/Validation/FieldValidation/DatetimeFieldValidator.php b/src/Core/Validation/FieldValidation/DatetimeFieldValidator.php new file mode 100644 index 000000000..855f8fb56 --- /dev/null +++ b/src/Core/Validation/FieldValidation/DatetimeFieldValidator.php @@ -0,0 +1,23 @@ +wholeSize = $wholeSize; + $this->decimalSize = $decimalSize; + } + + protected function validateValue(): ValidationResult + { + $result = parent::validateValue(); + if (!$result->isValid()) { + return $result; + } + // Example of how digits are stored in the database + // Decimal(5,2) is allowed a total of 5 digits, and will always round to 2 decimal places + // This means it has a maximum 3 digits before the decimal point + // + // Valid + // 123.99 + // 999.99 + // -999.99 + // 123.999 - will round to 124.00 + // + // Not valid + // 1234.9 - 4 digits the before the decimal point + // 999.999 - would be rounted to 10000000.00 which exceeds the 9 digits + + // Convert to absolute value - any the minus sign is not counted + $absValue = abs($this->value); + // Round to the decimal size which is what the database will do + $rounded = round($absValue, $this->decimalSize); + // Get formatted as a string, which will right pad with zeros to the decimal size + $rounded = number_format($rounded, $this->decimalSize, thousands_separator: ''); + // Count this number of digits - the minus 1 is for the decimal point + $digitCount = strlen((string) $rounded) - 1; + if ($digitCount > $this->wholeSize) { + $message = _t( + __CLASS__ . '.TOOLARGE', + 'Digit count cannot be greater than than {wholeSize}', + ['wholeSize' => $this->wholeSize] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/EmailFieldValidator.php b/src/Core/Validation/FieldValidation/EmailFieldValidator.php new file mode 100644 index 000000000..acd9ee737 --- /dev/null +++ b/src/Core/Validation/FieldValidation/EmailFieldValidator.php @@ -0,0 +1,19 @@ +allowedValues = $allowedValues; + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + // Allow empty strings + if ($this->value === '') { + return $result; + } + if (!in_array($this->value, $this->allowedValues, true)) { + $message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value'); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/FieldValidationInterface.php b/src/Core/Validation/FieldValidation/FieldValidationInterface.php new file mode 100644 index 000000000..2dafc4014 --- /dev/null +++ b/src/Core/Validation/FieldValidation/FieldValidationInterface.php @@ -0,0 +1,14 @@ +name = $name; + $this->value = $value; + } + + /** + * Validate the value + */ + public function validate(): ValidationResult + { + $result = ValidationResult::create(); + $validationResult = $this->validateValue($result); + if (!$validationResult->isValid()) { + $result->combineAnd($validationResult); + } + return $result; + } + + /** + * Inner validatation method that that is implemented by subclasses + */ + abstract protected function validateValue(): ValidationResult; +} diff --git a/src/Core/Validation/FieldValidation/FieldValidatorsTrait.php b/src/Core/Validation/FieldValidation/FieldValidatorsTrait.php new file mode 100644 index 000000000..721168f61 --- /dev/null +++ b/src/Core/Validation/FieldValidation/FieldValidatorsTrait.php @@ -0,0 +1,122 @@ + [null, 'getMyArg'], + * c) MyFieldValidator::class => null, + * + * a) Will create a FieldValidator and pass the name and value of the field as args to the constructor + * b) Will create a FieldValidator and pass the name, value, make a pass additional args, calling each + * non-null value on the field e.g. it will skip the first arg and call $field->getMyArg() for the second arg + * c) Will disable a previously set FieldValidator. This is useful to disable a FieldValidator that was set + * on a parent class + * + * You may only have a single instance of a FieldValidator class per field + */ + private static array $field_validators = []; + + /** + * Validate this field + */ + public function validate(): ValidationResult + { + $result = ValidationResult::create(); + // Skip validation if the field value is null + if ($this->getValue() === null) { + return $result; + } + $fieldValidators = $this->getFieldValidators(); + foreach ($fieldValidators as $fieldValidator) { + $validationResult = $fieldValidator->validate(); + if (!$validationResult->isValid()) { + $result->combineAnd($validationResult); + } + } + return $result; + } + + /** + * Get FieldValidators based on `field_validators` configuration + */ + private function getFieldValidators(): array + { + $fieldValidators = []; + // Used to disable a validator that was previously set with an int index + $disabledClasses = []; + $interface = FieldValidationInterface::class; + // temporary check, will make FormField implement FieldValidationInterface in a future PR + $tmp = FormField::class; + if (!is_a($this, $interface) && !is_a($this, $tmp)) { + $class = get_class($this); + throw new RuntimeException("Class $class does not implement interface $interface"); + } + /** @var FieldValidationInterface|Configurable $this */ + $name = $this->getName(); + $value = $this->getValueForValidation(); + // Field name is required for FieldValidators when called ValidationResult::addFieldMessage() + if ($name === '') { + throw new RuntimeException('Field name is blank'); + } + $classes = []; + $config = $this->config()->get('field_validators'); + foreach ($config as $indexOrClass => $classOrArgCallsOrDisable) { + $class = ''; + $argCalls = []; + $disable = false; + if (is_int($indexOrClass)) { + $class = $classOrArgCallsOrDisable; + } else { + $class = $indexOrClass; + $argCalls = $classOrArgCallsOrDisable; + $disable = $classOrArgCallsOrDisable === null; + } + if ($disable) { + $disabledClasses[$class] = true; + continue; + } else { + if (isset($disabledClasses[$class])) { + unset($disabledClasses[$class]); + } + } + if (!is_a($class, FieldValidator::class, true)) { + throw new RuntimeException("Class $class is not a FieldValidator"); + } + if (!is_array($argCalls)) { + throw new RuntimeException("argCalls for FieldValidator $class is not an array"); + } + $classes[$class] = $argCalls; + } + foreach (array_keys($disabledClasses) as $class) { + unset($classes[$class]); + } + foreach ($classes as $class => $argCalls) { + $args = [$name, $value]; + foreach ($argCalls as $i => $argCall) { + if (!is_string($argCall) && !is_null($argCall)) { + throw new RuntimeException("argCall $i for FieldValidator $class is not a string or null"); + } + if ($argCall) { + $args[] = call_user_func([$this, $argCall]); + } else { + $args[] = null; + } + } + $fieldValidators[$class] = Injector::inst()->createWithArgs($class, $args); + } + return array_values($fieldValidators); + } +} diff --git a/src/Core/Validation/FieldValidation/IntFieldValidator.php b/src/Core/Validation/FieldValidation/IntFieldValidator.php new file mode 100644 index 000000000..713d1b45f --- /dev/null +++ b/src/Core/Validation/FieldValidation/IntFieldValidator.php @@ -0,0 +1,46 @@ +value)) { + $message = _t(__CLASS__ . '.WRONGTYPE', 'Must be an integer'); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/IpFieldValidator.php b/src/Core/Validation/FieldValidation/IpFieldValidator.php new file mode 100644 index 000000000..789eb8fcf --- /dev/null +++ b/src/Core/Validation/FieldValidation/IpFieldValidator.php @@ -0,0 +1,30 @@ + Constraints\Ip::ALL, + ]; + } + + protected function getMessage(): string + { + return _t(__CLASS__ . '.INVALID', 'Invalid IP address'); + } +} diff --git a/src/Core/Validation/FieldValidation/LocaleFieldValidator.php b/src/Core/Validation/FieldValidation/LocaleFieldValidator.php new file mode 100644 index 000000000..accdf8a7c --- /dev/null +++ b/src/Core/Validation/FieldValidation/LocaleFieldValidator.php @@ -0,0 +1,22 @@ +value as $value) { + if (!in_array($value, $this->allowedValues, true)) { + $message = _t(__CLASS__ . '.NOTALLOWED', 'Not an allowed value'); + $result->addFieldError($this->name, $message, value: $value); + break; + } + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/NumericFieldValidator.php b/src/Core/Validation/FieldValidation/NumericFieldValidator.php new file mode 100644 index 000000000..8b2747471 --- /dev/null +++ b/src/Core/Validation/FieldValidation/NumericFieldValidator.php @@ -0,0 +1,56 @@ +minValue = $minValue; + $this->maxValue = $maxValue; + parent::__construct($name, $value); + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + if (!is_numeric($this->value) || is_string($this->value)) { + // Must be a numeric value, though not as a numeric string + $message = _t(__CLASS__ . '.WRONGTYPE', 'Must be numeric'); + $result->addFieldError($this->name, $message, value: $this->value); + return $result; + } elseif (isset($this->minValue) && $this->value < $this->minValue) { + $message = _t( + __CLASS__ . '.TOOSMALL', + 'Value cannot be less than {minValue}', + ['minValue' => $this->minValue] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } elseif (isset($this->maxValue) && $this->value > $this->maxValue) { + $message = _t( + __CLASS__ . '.TOOLARGE', + 'Value cannot be greater than {maxValue}', + ['maxValue' => $this->maxValue] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/StringFieldValidator.php b/src/Core/Validation/FieldValidation/StringFieldValidator.php new file mode 100644 index 000000000..85a88894f --- /dev/null +++ b/src/Core/Validation/FieldValidation/StringFieldValidator.php @@ -0,0 +1,65 @@ +minLength = $minLength; + $this->maxLength = $maxLength; + } + + protected function validateValue(): ValidationResult + { + $result = ValidationResult::create(); + if (!is_string($this->value)) { + $message = _t(__CLASS__ . '.WRONGTYPE', 'Must be a string'); + $result->addFieldError($this->name, $message, value: $this->value); + return $result; + } + $len = mb_strlen($this->value); + if (!is_null($this->minLength) && $len < $this->minLength) { + $message = _t( + __CLASS__ . '.TOOSHORT', + 'Must have at least {minLength} characters', + ['minLength' => $this->minLength] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } + if (!is_null($this->maxLength) && $len > $this->maxLength) { + $message = _t( + __CLASS__ . '.TOOLONG', + 'Can not have more than {maxLength} characters', + ['maxLength' => $this->maxLength] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/FieldValidation/TimeFieldValidator.php b/src/Core/Validation/FieldValidation/TimeFieldValidator.php new file mode 100644 index 000000000..f2a1c8d75 --- /dev/null +++ b/src/Core/Validation/FieldValidation/TimeFieldValidator.php @@ -0,0 +1,23 @@ +minValue = $minValue; + parent::__construct($name, $value, 0, $maxValue); + } + + protected function validateValue(): ValidationResult + { + $result = parent::validateValue(); + if ($this->value === 0) { + return $result; + } + if ($this->minValue && $this->value < $this->minValue) { + // Uses the same translation key as NumericFieldValidator + $message = _t( + NumericFieldValidator::class . '.TOOSMALL', + 'Value cannot be less than {minValue}', + ['minValue' => $this->minValue] + ); + $result->addFieldError($this->name, $message, value: $this->value); + } + return $result; + } +} diff --git a/src/Core/Validation/ValidationInterface.php b/src/Core/Validation/ValidationInterface.php new file mode 100644 index 000000000..1838503de --- /dev/null +++ b/src/Core/Validation/ValidationInterface.php @@ -0,0 +1,10 @@ +addFieldError(null, $message, $messageType, $code, $cast); + public function addError( + $message, + $messageType = ValidationResult::TYPE_ERROR, + $code = null, + $cast = ValidationResult::CAST_TEXT, + $value = ValidationResult::VALUE_UNSET, + ) { + return $this->addFieldError(null, $message, $messageType, $code, $cast, $value); } /** @@ -89,6 +100,7 @@ class ValidationResult * This can be usedful for ensuring no duplicate messages * @param string|bool $cast Cast type; One of the CAST_ constant definitions. * Bool values will be treated as plain text flag. + * @param mixed $value The value that failed validation * @return $this */ public function addFieldError( @@ -96,10 +108,11 @@ class ValidationResult $message, $messageType = ValidationResult::TYPE_ERROR, $code = null, - $cast = ValidationResult::CAST_TEXT + $cast = ValidationResult::CAST_TEXT, + $value = ValidationResult::VALUE_UNSET, ) { $this->isValid = false; - return $this->addFieldMessage($fieldName, $message, $messageType, $code, $cast); + return $this->addFieldMessage($fieldName, $message, $messageType, $code, $cast, $value); } /** @@ -112,11 +125,17 @@ class ValidationResult * This can be usedful for ensuring no duplicate messages * @param string|bool $cast Cast type; One of the CAST_ constant definitions. * Bool values will be treated as plain text flag. + * @param mixed $value The value that failed validation * @return $this */ - public function addMessage($message, $messageType = ValidationResult::TYPE_ERROR, $code = null, $cast = ValidationResult::CAST_TEXT) - { - return $this->addFieldMessage(null, $message, $messageType, $code, $cast); + public function addMessage( + $message, + $messageType = ValidationResult::TYPE_ERROR, + $code = null, + $cast = ValidationResult::CAST_TEXT, + $value = ValidationResult::VALUE_UNSET, + ) { + return $this->addFieldMessage(null, $message, $messageType, $code, $cast, $value); } /** @@ -130,6 +149,7 @@ class ValidationResult * This can be usedful for ensuring no duplicate messages * @param string|bool $cast Cast type; One of the CAST_ constant definitions. * Bool values will be treated as plain text flag. + * @param mixed $value The value that failed validation * @return $this */ public function addFieldMessage( @@ -137,7 +157,8 @@ class ValidationResult $message, $messageType = ValidationResult::TYPE_ERROR, $code = null, - $cast = ValidationResult::CAST_TEXT + $cast = ValidationResult::CAST_TEXT, + $value = ValidationResult::VALUE_UNSET, ) { if ($code && is_numeric($code)) { throw new InvalidArgumentException("Don't use a numeric code '$code'. Use a string."); @@ -151,7 +172,9 @@ class ValidationResult 'messageType' => $messageType, 'messageCast' => $cast, ]; - + if ($value !== ValidationResult::VALUE_UNSET) { + $metadata['value'] = $value; + } if ($code) { $this->messages[$code] = $metadata; } else { diff --git a/src/Forms/CompositeField.php b/src/Forms/CompositeField.php index 7c1cab23f..57ea04cfb 100644 --- a/src/Forms/CompositeField.php +++ b/src/Forms/CompositeField.php @@ -119,10 +119,8 @@ class CompositeField extends FormField * Returns the name (ID) for the element. * If the CompositeField doesn't have a name, but we still want the ID/name to be set. * This code generates the ID from the nested children. - * - * @return String $name */ - public function getName() + public function getName(): string { if ($this->name) { return $this->name; diff --git a/src/Forms/FieldGroup.php b/src/Forms/FieldGroup.php index 9a0d6c675..f7ced1903 100644 --- a/src/Forms/FieldGroup.php +++ b/src/Forms/FieldGroup.php @@ -106,7 +106,7 @@ class FieldGroup extends CompositeField * In some cases the FieldGroup doesn't have a title, but we still want * the ID / name to be set. This code, generates the ID from the nested children */ - public function getName() + public function getName(): string { if ($this->name) { return $this->name; diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php index 0d210436b..a5fa67041 100644 --- a/src/Forms/FormField.php +++ b/src/Forms/FormField.php @@ -15,6 +15,7 @@ use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\View\AttributesHTML; use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\FieldValidatorsTrait; /** * Represents a field in a form. @@ -44,6 +45,7 @@ class FormField extends RequestHandler { use AttributesHTML; use FormMessage; + use FieldValidatorsTrait; /** @see $schemaDataType */ const SCHEMA_DATA_TYPE_STRING = 'String'; @@ -424,12 +426,10 @@ class FormField extends RequestHandler /** * Returns the field name. - * - * @return string */ - public function getName() + public function getName(): string { - return $this->name; + return $this->name ?? ''; } /** @@ -443,16 +443,32 @@ class FormField extends RequestHandler } /** - * Returns the field value. + * Alias of getValue() * * @see FormField::setSubmittedValue() * @return mixed */ public function Value() + { + return $this->getValue(); + } + + /** + * Returns the field value. + */ + public function getValue(): mixed { return $this->value; } + /** + * Get the value of this field for field validation + */ + public function getValueForValidation(): mixed + { + return $this->getValue(); + } + /** * Method to save this form field into the given record. * @@ -1231,15 +1247,32 @@ class FormField extends RequestHandler } /** - * Abstract method each {@link FormField} subclass must implement, determines whether the field - * is valid or not based on the value. + * Subclasses can define an existing FieldValidatorClass to validate the FormField value + * They may also override this method to provide custom validation logic * * @param Validator $validator * @return bool */ public function validate($validator) { - return $this->extendValidationResult(true, $validator); + $isValid = true; + $result = ValidationResult::create(); + if ($this->getValue() === null) { + // Skip field validation if the value is null + return $this->extendValidationResult($isValid, $validator); + } + $fieldValidators = $this->getFieldValidators(); + foreach ($fieldValidators as $fieldValidator) { + $validationResult = $fieldValidator->validate(); + if (!$validationResult->isValid()) { + $result->combineAnd($validationResult); + } + } + if (!$result->isValid()) { + $isValid = false; + $validator->getResult()->combineAnd($result); + } + return $this->extendValidationResult($isValid, $validator); } /** diff --git a/src/Forms/SelectionGroup_Item.php b/src/Forms/SelectionGroup_Item.php index 55d021859..ab23c0cc8 100644 --- a/src/Forms/SelectionGroup_Item.php +++ b/src/Forms/SelectionGroup_Item.php @@ -43,7 +43,7 @@ class SelectionGroup_Item extends CompositeField return $this; } - function getValue() + function getValue(): mixed { return $this->value; } diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index e0d2b755d..33ca90086 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -1238,6 +1238,15 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro public function validate() { $result = ValidationResult::create(); + // Call DBField::validate() on every DBField + $specs = static::getSchema()->fieldSpecs(static::class); + foreach (array_keys($specs) as $fieldName) { + $dbField = $this->dbObject($fieldName); + $validationResult = $dbField->validate(); + if (!$validationResult->isValid()) { + $result->combineAnd($validationResult); + } + } $this->extend('updateValidate', $result); return $result; } diff --git a/src/ORM/FieldType/DBBigInt.php b/src/ORM/FieldType/DBBigInt.php index c92c2da69..9abb70c55 100644 --- a/src/ORM/FieldType/DBBigInt.php +++ b/src/ORM/FieldType/DBBigInt.php @@ -2,18 +2,24 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\BigIntFieldValidator; use SilverStripe\ORM\DB; /** * Represents a signed 8 byte integer field. Do note PHP running as 32-bit might not work with Bigint properly, as it * would convert the value to a float when queried from the database since the value is a 64-bit one. * - * @package framework - * @subpackage model - * @see Int + * BigInt is always signed i.e. can be negative + * Their range is -9223372036854775808 to 9223372036854775807 */ class DBBigInt extends DBInt { + private static array $field_validators = [ + // Remove parent validator and add BigIntValidator instead + IntFieldValidator::class => null, + BigIntFieldValidator::class, + ]; public function requireField(): void { @@ -24,7 +30,6 @@ class DBBigInt extends DBInt 'default' => $this->defaultVal, 'arrayValue' => $this->arrayValue ]; - $values = ['type' => 'bigint', 'parts' => $parts]; DB::require_field($this->tableName, $this->name, $values); } diff --git a/src/ORM/FieldType/DBBoolean.php b/src/ORM/FieldType/DBBoolean.php index c38ddcdfa..3f8107a0f 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\ORM\DB; 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 $defaultVal = false) + { + $this->setDefaultValue($defaultVal); parent::__construct($name); } @@ -35,6 +39,13 @@ class DBBoolean extends DBField DB::require_field($this->tableName, $this->name, $values); } + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value); + $this->value = $this->convertBooleanLikeValue($value); + return $this; + } + public function Nice(): string { return ($this->value) ? _t(__CLASS__ . '.YESANSWER', 'Yes') : _t(__CLASS__ . '.NOANSWER', 'No'); @@ -52,7 +63,7 @@ class DBBoolean extends DBField if ($this->value instanceof DBField) { $this->value->saveInto($dataObject); } else { - $dataObject->__set($fieldName, $this->value ? 1 : 0); + $dataObject->__set($fieldName, $this->value ? true : false); } } else { $class = static::class; @@ -70,37 +81,50 @@ class DBBoolean extends DBField $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) ->setEmptyString($anyText); } - public function nullValue(): ?int + public function nullValue(): int { return 0; } 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 2959caba7..9b04f701a 100644 --- a/src/ORM/FieldType/DBClassNameVarchar.php +++ b/src/ORM/FieldType/DBClassNameVarchar.php @@ -3,6 +3,7 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\ORM\FieldType\DBVarchar; +use SilverStripe\Core\Validation\FieldValidation\EnumFieldValidator; /** * An alternative to DBClassName that stores the class name as a varchar instead of an enum @@ -24,4 +25,8 @@ use SilverStripe\ORM\FieldType\DBVarchar; class DBClassNameVarchar extends DBVarchar { use DBClassNameTrait; + + private static array $field_validators = [ + EnumFieldValidator::class => ['getEnum'], + ]; } diff --git a/src/ORM/FieldType/DBComposite.php b/src/ORM/FieldType/DBComposite.php index 7060417ea..ed9a1ea93 100644 --- a/src/ORM/FieldType/DBComposite.php +++ b/src/ORM/FieldType/DBComposite.php @@ -8,6 +8,7 @@ use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\ORM\Queries\SQLSelect; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\CompositeFieldValidator; /** * Extend this class when designing a {@link DBField} that doesn't have a 1-1 mapping with a database field. @@ -25,6 +26,10 @@ use SilverStripe\Model\ModelData; */ abstract class DBComposite extends DBField { + private static array $field_validators = [ + CompositeFieldValidator::class, + ]; + /** * Similar to {@link DataObject::$db}, * holds an array of composite field names. @@ -190,6 +195,15 @@ abstract class DBComposite extends DBField return $this; } + public function getValueForValidation(): mixed + { + $fields = []; + foreach (array_keys($this->compositeDatabaseFields()) as $fieldName) { + $fields[] = $this->dbObject($fieldName); + } + return $fields; + } + /** * Bind this field to the model, and set the underlying table to that of the owner */ diff --git a/src/ORM/FieldType/DBCurrency.php b/src/ORM/FieldType/DBCurrency.php index 22d70d398..621410ced 100644 --- a/src/ORM/FieldType/DBCurrency.php +++ b/src/ORM/FieldType/DBCurrency.php @@ -31,6 +31,9 @@ class DBCurrency extends DBDecimal */ public function Nice(): string { + if (!is_float($this->value)) { + return ''; + } $val = static::config()->get('currency_symbol') . number_format(abs($this->value ?? 0.0) ?? 0.0, 2); if ($this->value < 0) { return "($val)"; @@ -44,6 +47,9 @@ class DBCurrency extends DBDecimal */ public function Whole(): string { + if (!is_float($this->value)) { + return ''; + } $val = static::config()->get('currency_symbol') . number_format(abs($this->value ?? 0.0) ?? 0.0, 0); if ($this->value < 0) { return "($val)"; @@ -53,15 +59,14 @@ class DBCurrency extends DBDecimal 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; + parent::setValue($value, $record, $markChanged); + if (is_string($this->value)) { + $symbol = static::config()->get('currency_symbol'); + $val = str_replace(['$', ',', $symbol], '', $this->value); + if (is_numeric($val)) { + $this->value = (float) $val; + } } - return $this; } diff --git a/src/ORM/FieldType/DBDate.php b/src/ORM/FieldType/DBDate.php index d8a271d78..aa54e30ba 100644 --- a/src/ORM/FieldType/DBDate.php +++ b/src/ORM/FieldType/DBDate.php @@ -12,6 +12,7 @@ use SilverStripe\ORM\DB; use SilverStripe\Security\Member; use SilverStripe\Security\Security; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator; /** * Represents a date field. @@ -33,6 +34,7 @@ class DBDate extends DBField { /** * Standard ISO format string for date in CLDR standard format + * This is equivalent to php date format "Y-m-d" e.g. 2024-08-31 */ public const ISO_DATE = 'y-MM-dd'; @@ -42,13 +44,14 @@ class DBDate extends DBField */ public const ISO_LOCALE = 'en_US'; + private static array $field_validators = [ + DateFieldValidator::class, + ]; + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static { - $value = $this->parseDate($value); - if ($value === false) { - throw new InvalidArgumentException( - "Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error." - ); + if ($value !== null) { + $value = $this->parseDate($value); } $this->value = $value; return $this; @@ -58,15 +61,10 @@ class DBDate extends DBField * Parse timestamp or iso8601-ish date into standard iso8601 format * * @param mixed $value - * @return string|null|false Formatted date, null if empty but valid, or false if invalid + * @return mixed Formatted date, or the original value if it couldn't be parsed */ - protected function parseDate(mixed $value): string|null|false + protected function parseDate(mixed $value): mixed { - // Skip empty values - if (empty($value) && !is_numeric($value)) { - return null; - } - // Determine value to parse if (is_array($value)) { $source = $value; // parse array @@ -74,19 +72,23 @@ class DBDate extends DBField $source = $value; // parse timestamp } else { // Convert US date -> iso, fix y2k, etc - $value = $this->fixInputDate($value); - if (is_null($value)) { - return null; + $fixedValue = $this->fixInputDate($value); + if ($fixedValue === '') { + // Dates with an invalid format will be caught by validator later + return $value; } - $source = strtotime($value ?? ''); // convert string to timestamp + // convert string to timestamp + $source = strtotime($fixedValue ?? ''); } - if ($value === false) { - return false; + if (!$source && $source !== 0 and $source !== '0') { + // Unable to parse date, keep as is so that the validator can catch it later + // Note that 0 and '0' are valid dates for Jan 1 1970 + return $value; } - // Format as iso8601 $formatter = $this->getInternalFormatter(); - return $formatter->format($source); + $ret = $formatter->format($source); + return $ret; } /** @@ -554,26 +556,14 @@ class DBDate extends DBField /** * Fix non-iso dates - * - * @param string $value - * @return string */ - protected function fixInputDate($value) + protected function fixInputDate(string $value): string { - // split [$year, $month, $day, $time] = $this->explodeDateString($value); - - if ((int)$year === 0 && (int)$month === 0 && (int)$day === 0) { - return null; + if (!checkdate((int) $month, (int) $day, (int) $year)) { + return ''; } - // Validate date - if (!checkdate($month ?? 0, $day ?? 0, $year ?? 0)) { - throw new InvalidArgumentException( - "Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error." - ); - } - - // Convert to y-m-d + // Convert to Y-m-d return sprintf('%d-%02d-%02d%s', $year, $month, $day, $time); } @@ -591,11 +581,8 @@ class DBDate extends DBField $value ?? '', $matches )) { - throw new InvalidArgumentException( - "Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error." - ); + return [0, 0, 0, '']; } - $parts = [ $matches['first'], $matches['second'], @@ -605,11 +592,6 @@ class DBDate extends DBField if ($parts[0] < 1000 && $parts[2] > 1000) { $parts = array_reverse($parts ?? []); } - if ($parts[0] < 1000 && (int)$parts[0] !== 0) { - throw new InvalidArgumentException( - "Invalid date: '$value'. Use " . DBDate::ISO_DATE . " to prevent this error." - ); - } $parts[] = $matches['time']; return $parts; } diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 287b64690..385ca27d1 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -13,6 +13,8 @@ use SilverStripe\Security\Member; use SilverStripe\Security\Security; use SilverStripe\View\TemplateGlobalProvider; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\DatetimeFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\DateFieldValidator; /** * Represents a date-time field. @@ -39,6 +41,7 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider /** * Standard ISO format string for date and time in CLDR standard format, * with a whitespace separating date and time (common database representation, e.g. in MySQL). + * This is equivalent to php date format "Y-m-d H:i:s" e.g. 2024-08-31 09:30:00 */ public const ISO_DATETIME = 'y-MM-dd HH:mm:ss'; @@ -48,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 deab1bcd0..1458e2d3f 100644 --- a/src/ORM/FieldType/DBDecimal.php +++ b/src/ORM/FieldType/DBDecimal.php @@ -2,6 +2,7 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Core\Validation\FieldValidation\DecimalFieldValidator; use SilverStripe\Forms\FormField; use SilverStripe\Forms\NumericField; use SilverStripe\ORM\DB; @@ -12,6 +13,10 @@ use SilverStripe\Model\ModelData; */ class DBDecimal extends DBField { + private static array $field_validators = [ + DecimalFieldValidator::class => ['getWholeSize', 'getDecimalSize'], + ]; + /** * Whole number size */ @@ -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 @@ class DBDecimal extends DBField return floor($this->value ?? 0.0); } + public function getWholeSize(): int + { + return $this->wholeSize; + } + + public function getDecimalSize(): int + { + return $this->decimalSize; + } + public function requireField(): void { $parts = [ 'datatype' => 'decimal', 'precision' => "$this->wholeSize,$this->decimalSize", - 'default' => $this->defaultValue, + 'default' => $this->getDefaultValue(), 'arrayValue' => $this->arrayValue ]; @@ -67,6 +77,16 @@ class DBDecimal extends DBField DB::require_field($this->tableName, $this->name, $values); } + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value, $record, $markChanged); + // Cast ints and numeric strings to floats + if (is_int($this->value) || (is_string($this->value) && is_numeric($this->value))) { + $this->value = (float) $value; + } + return $this; + } + public function saveInto(ModelData $model): void { $fieldName = $this->name; @@ -91,25 +111,25 @@ class DBDecimal extends DBField ->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 000000000..2c16c8ab6 --- /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 e71e6d17d..8f8e11af0 100644 --- a/src/ORM/FieldType/DBEnum.php +++ b/src/ORM/FieldType/DBEnum.php @@ -3,12 +3,14 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\Core\Config\Config; +use SilverStripe\Core\Validation\FieldValidation\EnumFieldValidator; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; use SilverStripe\Forms\SelectField; use SilverStripe\Core\ArrayLib; use SilverStripe\ORM\Connect\MySQLDatabase; use SilverStripe\ORM\DB; +use SilverStripe\Model\ModelData; /** * Class Enum represents an enumeration of a set of strings. @@ -17,6 +19,10 @@ use SilverStripe\ORM\DB; */ class DBEnum extends DBString { + private static array $field_validators = [ + EnumFieldValidator::class => ['getEnum'], + ]; + /** * List of enum values */ @@ -73,14 +79,14 @@ class DBEnum extends DBString // If there's a default, then use this if ($default && !is_int($default)) { - if (in_array($default, $enum ?? [])) { + if (in_array($default, $enum)) { $this->setDefault($default); } else { throw new \InvalidArgumentException( "Enum::__construct() The default value '$default' does not match any item in the enumeration" ); } - } elseif (is_int($default) && $default < count($enum ?? [])) { + } elseif (is_int($default) && $default < count($enum)) { // Set to specified index if given $this->setDefault($enum[$default]); } else { diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php index 2752c2ff9..6a585980d 100644 --- a/src/ORM/FieldType/DBField.php +++ b/src/ORM/FieldType/DBField.php @@ -10,6 +10,8 @@ use SilverStripe\Forms\TextField; use SilverStripe\ORM\Filters\SearchFilter; use SilverStripe\ORM\Queries\SQLSelect; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\FieldValidatorsTrait; +use SilverStripe\Core\Validation\FieldValidation\FieldValidationInterface; /** * Single field in the database. @@ -41,8 +43,9 @@ use SilverStripe\Model\ModelData; * } * */ -abstract class DBField extends ModelData implements DBIndexable +abstract class DBField extends ModelData implements DBIndexable, FieldValidationInterface { + use FieldValidatorsTrait; /** * Raw value of this field @@ -99,13 +102,15 @@ 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 +127,8 @@ abstract class DBField extends ModelData implements DBIndexable } $this->setOptions($options); } + // Setting value needs to happen below the call to setOptions() in case the default value is set there + $this->value = $this->getDefaultValue(); parent::__construct(); } @@ -162,7 +169,7 @@ abstract class DBField extends ModelData implements DBIndexable * * If you try an alter the name a warning will be thrown. */ - public function setName(?string $name): static + public function setName(string $name): static { if ($this->name && $this->name !== $name) { user_error("DBField::setName() shouldn't be called once a DBField already has a name." @@ -190,6 +197,18 @@ abstract class DBField extends ModelData implements DBIndexable return $this->value; } + /** + * Get the value of this field for field validation + */ + public function getValueForValidation(): mixed + { + $value = $this->getValue(); + if (is_null($value)) { + return $this->nullValue(); + } + return $value; + } + /** * Set the value of this field in various formats. * Used by {@link DataObject->getField()}, {@link DataObject->setCastedField()} @@ -215,7 +234,7 @@ abstract class DBField extends ModelData implements DBIndexable */ public function getDefaultValue(): mixed { - return $this->defaultVal; + return $this->defaultValue; } /** @@ -223,7 +242,7 @@ abstract class DBField extends ModelData implements DBIndexable */ public function setDefaultValue(mixed $defaultValue): static { - $this->defaultVal = $defaultValue; + $this->defaultValue = $defaultValue; return $this; } diff --git a/src/ORM/FieldType/DBFloat.php b/src/ORM/FieldType/DBFloat.php index c4eadc78c..6b6ab5287 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); } @@ -58,7 +56,7 @@ class DBFloat extends DBField return $field; } - public function nullValue(): ?int + public function nullValue(): int { return 0; } diff --git a/src/ORM/FieldType/DBForeignKey.php b/src/ORM/FieldType/DBForeignKey.php index c586b891f..c46cd7db4 100644 --- a/src/ORM/FieldType/DBForeignKey.php +++ b/src/ORM/FieldType/DBForeignKey.php @@ -63,6 +63,11 @@ class DBForeignKey extends DBInt if ($record instanceof DataObject) { $this->object = $record; } + // Convert blank string to 0, this is sometimes required when calling DataObject::setCastedValue() + // after a form submission where the value is a blank string when no value is selected + if ($value === '') { + $value = 0; + } return parent::setValue($value, $record, $markChanged); } } diff --git a/src/ORM/FieldType/DBInt.php b/src/ORM/FieldType/DBInt.php index ca12b2ead..9c004d2c0 100644 --- a/src/ORM/FieldType/DBInt.php +++ b/src/ORM/FieldType/DBInt.php @@ -2,33 +2,46 @@ namespace SilverStripe\ORM\FieldType; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; use SilverStripe\Forms\FormField; use SilverStripe\Forms\NumericField; use SilverStripe\Model\List\ArrayList; use SilverStripe\ORM\DB; use SilverStripe\Model\List\SS_List; use SilverStripe\Model\ArrayData; +use SilverStripe\Model\ModelData; /** - * Represents a signed 32 bit integer field. + * Represents a signed 32 bit integer field + * + * Ints are always signed i.e. they can be negative + * Their range is -2147483648 to 2147483647 */ class DBInt extends DBField { + private static array $field_validators = [ + IntFieldValidator::class + ]; + public function __construct(?string $name = null, int $defaultVal = 0) { - $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 getField($fieldName): mixed { - return (int) $this->value; + return $this->value; + } + + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value, $record, $markChanged); + // Cast int like strings as ints + if (is_string($this->value) && preg_match('/^-?\d+$/', $this->value)) { + $this->value = (int) $value; + } + return $this; } /** @@ -72,7 +85,7 @@ class DBInt extends DBField return NumericField::create($this->name, $title); } - public function nullValue(): ?int + public function nullValue(): int { return 0; } diff --git a/src/ORM/FieldType/DBIp.php b/src/ORM/FieldType/DBIp.php new file mode 100644 index 000000000..ecd28a0dd --- /dev/null +++ b/src/ORM/FieldType/DBIp.php @@ -0,0 +1,13 @@ + null, + EnumFieldValidator::class => null, + // enable multi enum field validator + MultiEnumFieldValidator::class => ['getEnum'], + ]; + public function __construct($name = null, $enum = null, $default = null) { // MultiEnum needs to take care of its own defaults @@ -34,6 +45,15 @@ class DBMultiEnum extends DBEnum } } + public function getValueForValidation(): array + { + $value = parent::getValueForValidation(); + if (is_array($value)) { + return $value; + } + return explode(',', (string) $value); + } + public function requireField(): void { $charset = Config::inst()->get(MySQLDatabase::class, 'charset'); diff --git a/src/ORM/FieldType/DBPercentage.php b/src/ORM/FieldType/DBPercentage.php index 3ceab5265..f6ea29410 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 @@ use SilverStripe\Model\ModelData; */ class DBPercentage extends DBDecimal { + private static array $field_validators = [ + DecimalFieldValidator::class => ['getWholeSize', 'getDecimalSize', 'getMinValue', 'getMaxValue'], + ]; + /** * Create a new Decimal field. */ @@ -29,6 +34,16 @@ class DBPercentage extends DBDecimal 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 62b792606..8fad8ad43 100644 --- a/src/ORM/FieldType/DBPolymorphicForeignKey.php +++ b/src/ORM/FieldType/DBPolymorphicForeignKey.php @@ -5,12 +5,18 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\Forms\FormField; use SilverStripe\ORM\DataObject; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\CompositeFieldValidator; /** * A special ForeignKey class that handles relations with arbitrary class types */ class DBPolymorphicForeignKey extends DBComposite { + private static array $field_validators = [ + // Disable parent field validator + CompositeFieldValidator::class => null, + ]; + private static bool $index = true; private static array $composite_db = [ diff --git a/src/ORM/FieldType/DBString.php b/src/ORM/FieldType/DBString.php index 99d597aa9..ae98785f3 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 fac285be1..cbd778739 100644 --- a/src/ORM/FieldType/DBTime.php +++ b/src/ORM/FieldType/DBTime.php @@ -11,6 +11,7 @@ use SilverStripe\ORM\DB; use SilverStripe\Security\Member; use SilverStripe\Security\Security; use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\TimeFieldValidator; /** * Represents a column in the database with the type 'Time'. @@ -26,33 +27,30 @@ class DBTime extends DBField { /** * Standard ISO format string for time in CLDR standard format + * This is equivalent to php date format "H:i:s" e.g. 09:30:00 */ public const ISO_TIME = 'HH:mm:ss'; + private static array $field_validators = [ + TimeFieldValidator::class, + ]; + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static { $value = $this->parseTime($value); - if ($value === false) { - throw new InvalidArgumentException( - 'Invalid date passed. Use ' . $this->getISOFormat() . ' to prevent this error.' - ); - } $this->value = $value; return $this; } /** * Parse timestamp or iso8601-ish date into standard iso8601 format - * - * @return string|null|false Formatted time, null if empty but valid, or false if invalid */ - protected function parseTime(mixed $value): string|null|false + protected function parseTime(mixed $value): mixed { - // Skip empty values + // Return empty dates as-is, they will get caught by validator later if (empty($value) && !is_numeric($value)) { - return null; + return $value; } - // Determine value to parse if (is_array($value)) { $source = $value; // parse array @@ -62,14 +60,16 @@ class DBTime extends DBField // Convert using strtotime $source = strtotime($value ?? ''); } - if ($value === false) { - return false; - } - // Format as iso8601 $formatter = $this->getFormatter(); $formatter->setPattern($this->getISOFormat()); - return $formatter->format($source); + $formatted = $formatter->format($source); + // If it failed to format, return the original value so that it can + // be caught by the validator + if ($formatted === false) { + return $value; + } + return $formatted; } /** diff --git a/src/ORM/FieldType/DBUrl.php b/src/ORM/FieldType/DBUrl.php new file mode 100644 index 000000000..ab9435c65 --- /dev/null +++ b/src/ORM/FieldType/DBUrl.php @@ -0,0 +1,22 @@ +name, $title); + $field->setMaxLength($this->getSize()); + return $field; + } +} diff --git a/src/ORM/FieldType/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php index 3081ad34b..b0cbbc017 100644 --- a/src/ORM/FieldType/DBVarchar.php +++ b/src/ORM/FieldType/DBVarchar.php @@ -8,6 +8,7 @@ use SilverStripe\Forms\NullableField; use SilverStripe\Forms\TextField; use SilverStripe\ORM\Connect\MySQLDatabase; use SilverStripe\ORM\DB; +use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator; /** * Class Varchar represents a variable-length string of up to 255 characters, designed to store raw text @@ -18,6 +19,10 @@ use SilverStripe\ORM\DB; */ class DBVarchar extends DBString { + private static array $field_validators = [ + StringFieldValidator::class => [null, 'getSize'], + ]; + private static array $casting = [ 'Initial' => 'Text', 'URL' => 'Text', diff --git a/src/ORM/FieldType/DBYear.php b/src/ORM/FieldType/DBYear.php index 04618cae3..5fec4ea75 100644 --- a/src/ORM/FieldType/DBYear.php +++ b/src/ORM/FieldType/DBYear.php @@ -5,12 +5,23 @@ namespace SilverStripe\ORM\FieldType; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; use SilverStripe\ORM\DB; +use SilverStripe\Model\ModelData; +use SilverStripe\Core\Validation\FieldValidation\YearFieldValidator; /** - * Represents a single year field. + * Represents a single year field */ class DBYear extends DBField { + // MySQL year datatype supports years between 1901 and 2155 + // https://dev.mysql.com/doc/refman/8.0/en/year.html + 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 +36,51 @@ class DBYear extends DBField return $selectBox; } + public function setValue(mixed $value, null|array|ModelData $record = null, bool $markChanged = true): static + { + parent::setValue($value, $record, $markChanged); + // 0 is used to represent a null value, which will be stored as 0000 in MySQL + if ($this->value === '0000') { + $this->value = 0; + } + // shorthand for 2000 in MySQL + if ($this->value === '00') { + $this->value = 2000; + } + // convert string int to int + // string int and int are both valid in MySQL, though only use int internally + if (is_string($this->value) && preg_match('#^\d+$#', (string) $this->value)) { + $this->value = (int) $this->value; + } + if (!is_int($this->value)) { + return $this; + } + // shorthand for 2001-2069 in MySQL + if ($this->value >= 1 && $this->value <= 69) { + $this->value = 2000 + $this->value; + } + // shorthand for 1970-1999 in MySQL + if ($this->value >= 70 && $this->value <= 99) { + $this->value = 1900 + $this->value; + } + return $this; + } + + public function 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 +88,10 @@ class DBYear extends DBField private function getDefaultOptions(?int $start = null, ?int $end = null): array { if (!$start) { - $start = (int)date('Y'); + $start = (int) date('Y'); } if (!$end) { - $end = 1900; + $end = DBYear::MIN_YEAR; } $years = []; for ($i = $start; $i >= $end; $i--) { diff --git a/src/Security/Member.php b/src/Security/Member.php index 94ea15ccf..f4ef6642d 100644 --- a/src/Security/Member.php +++ b/src/Security/Member.php @@ -444,8 +444,8 @@ class Member extends DataObject if (!$this->PasswordExpiry) { return false; } - - return strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry ?? ''); + $expired = strtotime(date('Y-m-d')) >= strtotime($this->PasswordExpiry ?? ''); + return $expired; } /** @@ -1691,7 +1691,7 @@ class Member extends DataObject // Overwrite the Password property with the hashed value $this->Password = $encryption_details['password']; - $this->Salt = $encryption_details['salt']; + $this->Salt = $encryption_details['salt'] ?: ''; $this->PasswordEncryption = $encryption_details['algorithm']; // If we haven't manually set a password expiry diff --git a/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php new file mode 100644 index 000000000..479068540 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/BigIntFieldValidatorTest.php @@ -0,0 +1,75 @@ + [ + 'value' => 123, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'expected' => true, + ], + 'valid-max-int' => [ + 'value' => 9223372036854775807, + 'expected' => true, + ], + 'valid-min-int' => [ + 'value' => '-9223372036854775808', + 'expected' => true, + ], + // Note: cannot test out of range values as they casting them to int + // will change the value to PHP_INT_MIN/PHP_INT_MAX + 'invalid-string-int' => [ + 'value' => '123', + 'expected' => false, + ], + 'invalid-float' => [ + 'value' => 123.45, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + // On 64-bit systems, -9223372036854775808 will end up as a float + // however it works correctly when cast to an int + if ($value === '-9223372036854775808') { + $value = (int) $value; + } + $validator = new BigIntFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/BooleanFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/BooleanFieldValidatorTest.php new file mode 100644 index 000000000..adabc29c1 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/BooleanFieldValidatorTest.php @@ -0,0 +1,72 @@ + [ + 'value' => true, + 'expected' => true, + ], + 'valid-false' => [ + 'value' => false, + '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-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-string' => [ + 'value' => 'abc', + 'expected' => false, + ], + 'invalid-int' => [ + 'value' => 123, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [], + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new 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 000000000..b8c12004f --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/CompositeFieldValidatorTest.php @@ -0,0 +1,97 @@ + [ + 'valueBoolean' => true, + 'valueString' => 'fish', + 'valueIsNull' => false, + 'exception' => null, + 'expected' => true, + ], + 'exception-not-iterable' => [ + 'valueBoolean' => true, + 'valueString' => 'not-iterable', + 'valueIsNull' => false, + 'exception' => InvalidArgumentException::class, + 'expected' => true, + ], + 'exception-not-field-validator' => [ + 'valueBoolean' => true, + 'valueString' => 'no-field-validation', + 'valueIsNull' => false, + 'exception' => InvalidArgumentException::class, + 'expected' => true, + ], + 'exception-do-not-skip-null' => [ + 'valueBoolean' => true, + 'valueString' => 'fish', + 'valueIsNull' => true, + 'exception' => InvalidArgumentException::class, + 'expected' => true, + ], + 'invalid-bool-field' => [ + 'valueBoolean' => 'dog', + 'valueString' => 'fish', + 'valueIsNull' => false, + 'exception' => null, + 'expected' => false, + ], + 'invalid-string-field' => [ + 'valueBoolean' => true, + 'valueString' => 456.789, + 'valueIsNull' => false, + 'exception' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate( + mixed $valueBoolean, + mixed $valueString, + bool $valueIsNull, + ?string $exception, + bool $expected + ): void { + if ($exception) { + $this->expectException($exception); + } + if ($valueIsNull) { + $iterable = null; + } else { + $booleanField = new DBBoolean('BooleanField'); + $booleanField->setValue($valueBoolean); + if ($exception && $valueString === 'no-field-validation') { + $stringField = new stdClass(); + } else { + $stringField = new DBVarchar('StringField'); + $stringField->setValue($valueString); + } + if ($exception && $valueString === 'not-iterable') { + $iterable = 'banana'; + } else { + $iterable = [$booleanField, $stringField]; + } + } + $validator = new CompositeFieldValidator('MyField', $iterable); + $result = $validator->validate(); + if (!$exception) { + $this->assertSame($expected, $result->isValid()); + } + } +} diff --git a/tests/php/Core/Validation/FieldValidation/DateFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/DateFieldValidatorTest.php new file mode 100644 index 000000000..2b881586f --- /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, + ], + 'invalid' => [ + 'value' => '2020-02-30', + 'expected' => false, + ], + 'invalid-wrong-format' => [ + 'value' => '15-09-2020', + 'expected' => false, + ], + 'invalid-date-time' => [ + 'value' => '2020-09-15 13:34:56', + 'expected' => false, + ], + 'invalid-time' => [ + 'value' => '13:34:56', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new DateFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php new file mode 100644 index 000000000..2ac3d3c76 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/DatetimeFieldValidatorTest.php @@ -0,0 +1,52 @@ + [ + 'value' => '2020-09-15 13:34:56', + 'expected' => true, + ], + 'invalid-date' => [ + 'value' => '2020-02-30 13:34:56', + 'expected' => false, + ], + 'invalid-time' => [ + 'value' => '2020-02-15 13:99:56', + 'expected' => false, + ], + 'invalid-wrong-format' => [ + 'value' => '15-09-2020 13:34:56', + 'expected' => false, + ], + 'invalid-date-only' => [ + 'value' => '2020-09-15', + 'expected' => false, + ], + 'invalid-time-only' => [ + 'value' => '13:34:56', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new DatetimeFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/DecimalFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/DecimalFieldValidatorTest.php new file mode 100644 index 000000000..176f8f520 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/DecimalFieldValidatorTest.php @@ -0,0 +1,138 @@ + [ + 'value' => 123.45, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-negative' => [ + 'value' => -123.45, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-rounded-dp' => [ + 'value' => 123.456, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-rounded-up' => [ + 'value' => 123.999, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-int' => [ + 'value' => 123, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-max' => [ + 'value' => 999.99, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'valid-max-negative' => [ + 'value' => -999.99, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => true, + ], + 'invalid-rounded-to-6-digts' => [ + 'value' => 999.999, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-too-long' => [ + 'value' => 1234.56, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-too-long-3dp' => [ + 'value' => 123.456, + 'wholeSize' => 5, + 'decimalSize' => 3, + 'expected' => false, + ], + 'invalid-too-long-1dp' => [ + 'value' => 123.4, + 'wholeSize' => 5, + 'decimalSize' => 3, + 'expected' => false, + ], + 'invalid-too-long-int' => [ + 'value' => 123, + 'wholeSize' => 5, + 'decimalSize' => 3, + 'expected' => false, + ], + 'invalid-string' => [ + 'value' => '123.45', + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123.45], + 'wholeSize' => 5, + 'decimalSize' => 2, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, int $wholeSize, int $decimalSize, bool $expected): void + { + $validator = new DecimalFieldValidator('MyField', $value, $wholeSize, $decimalSize); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php new file mode 100644 index 000000000..2885bdf36 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/EmailFieldValidatorTest.php @@ -0,0 +1,37 @@ + [ + 'value' => 'test@example.com', + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'fish', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new EmailFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/EnumFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/EnumFieldValidatorTest.php new file mode 100644 index 000000000..6c28c5d11 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/EnumFieldValidatorTest.php @@ -0,0 +1,54 @@ + [ + 'value' => 'cat', + 'allowedValues' => ['cat', 'dog'], + 'expected' => true, + ], + 'valid-int' => [ + 'value' => 123, + 'allowedValues' => [123, 456], + 'expected' => true, + ], + 'valid-none' => [ + 'value' => '', + 'allowedValues' => ['cat', 'dog'], + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'fish', + 'allowedValues' => ['cat', 'dog'], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'allowedValues' => ['cat', 'dog'], + 'expected' => false, + ], + 'invalid-strict' => [ + 'value' => '123', + 'allowedValues' => [123, 456], + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, array $allowedValues, bool $expected): void + { + $validator = new EnumFieldValidator('MyField', $value, $allowedValues); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php new file mode 100644 index 000000000..a18f3b4a8 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/IntFieldValidatorTest.php @@ -0,0 +1,76 @@ + [ + 'value' => 123, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'expected' => true, + ], + 'valid-max-int' => [ + 'value' => 2147483647, + 'expected' => true, + ], + 'valid-min-int' => [ + 'value' => -2147483648, + 'expected' => true, + ], + 'invalid-out-of-bounds' => [ + 'value' => 2147483648, + 'expected' => false, + ], + 'invalid-out-of-negative-bounds' => [ + 'value' => -2147483649, + 'expected' => false, + ], + 'invalid-string-int' => [ + 'value' => '123', + 'expected' => false, + ], + 'invalid-float' => [ + 'value' => 123.45, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new IntFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php new file mode 100644 index 000000000..9a91614cc --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/IpFieldValidatorTest.php @@ -0,0 +1,45 @@ + [ + 'value' => '127.0.0.1', + 'expected' => true, + ], + 'valid-ipv6' => [ + 'value' => '0:0:0:0:0:0:0:1', + 'expected' => true, + ], + 'valid-ipv6-short' => [ + 'value' => '::1', + 'expected' => true, + ], + 'invalid' => [ + 'value' => '12345', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new IpFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php new file mode 100644 index 000000000..cab128a7b --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/LocaleFieldValidatorTest.php @@ -0,0 +1,61 @@ + [ + 'value' => 'de_DE', + 'expected' => true, + ], + 'valid-dash' => [ + 'value' => 'de-DE', + 'expected' => true, + ], + 'valid-short' => [ + 'value' => 'de', + 'expected' => true, + ], + 'invalid' => [ + 'value' => 'zz_ZZ', + 'expected' => false, + ], + 'invalid-dash' => [ + 'value' => 'zz-ZZ', + 'expected' => false, + ], + 'invalid-short' => [ + 'value' => 'zz', + 'expected' => false, + ], + 'invalid-dashes' => [ + 'value' => '-----', + 'expected' => false, + ], + 'invalid-donut' => [ + 'value' => 'donut', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new LocaleFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php new file mode 100644 index 000000000..8e286b371 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/MultiEnumFieldValidatorTest.php @@ -0,0 +1,84 @@ + [ + 'value' => ['cat'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => true, + ], + 'valid-multi-string' => [ + 'value' => ['cat', 'dog'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => true, + ], + 'valid-none' => [ + 'value' => [], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => true, + ], + 'valid-int' => [ + 'value' => [123], + 'allowedValues' => [123, 456], + 'exception' => false, + 'expected' => true, + ], + 'exception-not-array' => [ + 'value' => 'cat,dog', + 'allowedValues' => ['cat', 'dog'], + 'exception' => true, + 'expected' => false, + ], + 'invalid' => [ + 'value' => ['fish'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => [null], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => false, + ], + 'invalid-multi' => [ + 'value' => ['dog', 'fish'], + 'allowedValues' => ['cat', 'dog'], + 'exception' => false, + 'expected' => false, + ], + 'invalid-strict' => [ + 'value' => ['123'], + 'allowedValues' => [123, 456], + 'exception' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, array $allowedValues, bool $exception, bool $expected): void + { + if ($exception) { + $this->expectException(InvalidArgumentException::class); + } + $validator = new MultiEnumFieldValidator('MyField', $value, $allowedValues); + $result = $validator->validate(); + if (!$exception) { + $this->assertSame($expected, $result->isValid()); + } + } +} diff --git a/tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php new file mode 100644 index 000000000..a0569eaa7 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/NumericFieldValidatorTest.php @@ -0,0 +1,80 @@ + [ + 'value' => 123, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + 'expected' => true, + ], + 'valid-negative-int' => [ + 'value' => -123, + 'expected' => true, + ], + 'valid-float' => [ + 'value' => 123.45, + 'expected' => true, + ], + 'valid-negative-float' => [ + 'value' => -123.45, + 'expected' => true, + ], + 'valid-max-int' => [ + 'value' => PHP_INT_MAX, + 'expected' => true, + ], + 'valid-min-int' => [ + 'value' => PHP_INT_MIN, + 'expected' => true, + ], + 'valid-max-float' => [ + 'value' => PHP_FLOAT_MAX, + 'expected' => true, + ], + 'valid-min-float' => [ + 'value' => PHP_FLOAT_MIN, + 'expected' => true, + ], + 'invalid-string' => [ + 'value' => '123', + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => [123], + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + 'invalid-true' => [ + 'value' => true, + 'expected' => false, + ], + 'invalid-false' => [ + 'value' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new NumericFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php new file mode 100644 index 000000000..9b8d2959c --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/StringFieldValidatorTest.php @@ -0,0 +1,149 @@ + [ + 'value' => 'fish', + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-blank' => [ + 'value' => '', + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => true, + ], + 'valid-max' => [ + 'value' => 'fish', + 'minLength' => 0, + 'maxLength' => 4, + 'exception' => false, + 'expected' => true, + ], + 'valid-less-than-max-null-min' => [ + 'value' => 'fish', + 'minLength' => null, + 'maxLength' => 4, + 'exception' => false, + 'expected' => true, + ], + 'valid-less-than-max-unicode' => [ + 'value' => '☕☕☕☕', + 'minLength' => 0, + 'maxLength' => 4, + 'exception' => false, + 'expected' => true, + ], + 'exception-negative-min' => [ + 'value' => 'fish', + 'minLength' => -1, + 'maxLength' => null, + 'exception' => true, + 'expected' => false, + ], + 'invalid-below-min' => [ + 'value' => 'fish', + 'minLength' => 5, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-below-min-unicode' => [ + 'value' => '☕☕☕☕', + 'minLength' => 5, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-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-null' => [ + 'value' => null, + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + 'invalid-array' => [ + 'value' => ['fish'], + 'minLength' => null, + 'maxLength' => null, + 'exception' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, ?int $minLength, ?int $maxLength, bool $exception, bool $expected): void + { + if ($exception) { + $this->expectException(InvalidArgumentException::class); + } + $validator = new StringFieldValidator('MyField', $value, $minLength, $maxLength); + $result = $validator->validate(); + if (!$exception) { + $this->assertSame($expected, $result->isValid()); + } + } +} diff --git a/tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php new file mode 100644 index 000000000..e2026d919 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/TimeFieldValidatorTest.php @@ -0,0 +1,48 @@ + [ + 'value' => '13:34:56', + 'expected' => true, + ], + 'invalid' => [ + 'value' => '13:99:56', + 'expected' => false, + ], + 'invalid-wrong-format' => [ + 'value' => '13-34-56', + 'expected' => false, + ], + 'invalid-date-time' => [ + 'value' => '2020-09-15 13:34:56', + 'expected' => false, + ], + 'invalid-date' => [ + 'value' => '2020-09-15', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new TimeFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php new file mode 100644 index 000000000..f5b463eb2 --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/UrlFieldValidatorTest.php @@ -0,0 +1,45 @@ + [ + 'value' => 'https://www.example.com', + 'expected' => true, + ], + 'valid-http' => [ + 'value' => 'https://www.example.com', + 'expected' => true, + ], + 'invalid-ftp' => [ + 'value' => 'ftp://www.example.com', + 'expected' => false, + ], + 'invalid-no-scheme' => [ + 'value' => 'www.example.com', + 'expected' => false, + ], + 'invalid-null' => [ + 'value' => null, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidate')] + public function testValidate(mixed $value, bool $expected): void + { + $validator = new UrlFieldValidator('MyField', $value); + $result = $validator->validate(); + $this->assertSame($expected, $result->isValid()); + } +} diff --git a/tests/php/Core/Validation/FieldValidation/YearFieldValidatorTest.php b/tests/php/Core/Validation/FieldValidation/YearFieldValidatorTest.php new file mode 100644 index 000000000..9ac4ef32d --- /dev/null +++ b/tests/php/Core/Validation/FieldValidation/YearFieldValidatorTest.php @@ -0,0 +1,43 @@ + [ + 'value' => 2021, + 'expected' => true, + ], + 'valid-zero' => [ + 'value' => 0, + '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 95ad6f7f5..7c75ddb31 100644 --- a/tests/php/Forms/FormFieldTest.php +++ b/tests/php/Forms/FormFieldTest.php @@ -39,6 +39,7 @@ use SilverStripe\Security\PermissionCheckboxSetField_Readonly; 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 000000000..1d52bf6b3 --- /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 6f096628b..c8f221cf1 100644 --- a/tests/php/ORM/DBCompositeTest.php +++ b/tests/php/ORM/DBCompositeTest.php @@ -6,6 +6,9 @@ use SilverStripe\ORM\FieldType\DBMoney; use SilverStripe\ORM\DataObject; use SilverStripe\Dev\SapphireTest; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\ORM\FieldType\DBVarchar; +use SilverStripe\ORM\FieldType\DBDecimal; class DBCompositeTest extends SapphireTest { @@ -140,4 +143,12 @@ class DBCompositeTest extends SapphireTest // $this->assertSame($moneyField, $obj->dbObject('DoubleMoney')); // $this->assertEquals(20, $obj->dbObject('DoubleMoney')->getAmount()); } + + public function testGetValueForValidation(): void + { + $obj = DBCompositeTest\DBDoubleMoney::create(); + $expected = [DBVarchar::class, DBDecimal::class]; + $actual = array_map('get_class', $obj->getValueForValidation()); + $this->assertSame($expected, $actual); + } } diff --git a/tests/php/ORM/DBCurrencyTest.php b/tests/php/ORM/DBCurrencyTest.php index 7a1609126..c2df4cfaf 100644 --- a/tests/php/ORM/DBCurrencyTest.php +++ b/tests/php/ORM/DBCurrencyTest.php @@ -5,6 +5,7 @@ namespace SilverStripe\ORM\Tests; 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 @@ class DBCurrencyTest extends SapphireTest // 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 @@ class DBCurrencyTest extends SapphireTest // 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 @@ class DBCurrencyTest extends SapphireTest $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/DBDateTest.php b/tests/php/ORM/DBDateTest.php index 96e606c93..880dc1b77 100644 --- a/tests/php/ORM/DBDateTest.php +++ b/tests/php/ORM/DBDateTest.php @@ -10,6 +10,7 @@ use SilverStripe\ORM\FieldType\DBDate; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBField; use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Core\Validation\ValidationException; class DBDateTest extends SapphireTest { @@ -91,20 +92,6 @@ class DBDateTest extends SapphireTest ); } - public function testMDYConversion() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid date: '3/16/2003'. Use y-MM-dd to prevent this error."); - DBField::create_field('Date', '3/16/2003'); - } - - public function testY2kCorrection() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Invalid date: '03-03-04'. Use y-MM-dd to prevent this error."); - DBField::create_field('Date', '03-03-04'); - } - public function testInvertedYearCorrection() { // iso8601 expects year first, but support year last @@ -194,31 +181,70 @@ class DBDateTest extends SapphireTest ); } - public function testSetNullAndZeroValues() + public static function provideSetValue() { - $date = DBField::create_field('Date', ''); - $this->assertNull($date->getValue(), 'Empty string evaluates to NULL'); + return [ + 'date' => [ + 'value' => '1998-04-05', + 'expected' => '1998-04-05' + ], + 'int' => [ + 'value' => 978797878, + 'expected' => '2001-01-06' + ], + 'int-string' => [ + 'value' => '978797878', + 'expected' => '2001-01-06' + ], + 'blank-string' => [ + 'value' => '', + 'expected' => '' + ], + 'null' => [ + 'value' => null, + 'expected' => null + ], + 'false' => [ + 'value' => false, + 'expected' => false + ], + 'empty-array' => [ + 'value' => [], + 'expected' => [] + ], + 'zero-string' => [ + 'value' => '0', + 'expected' => '1970-01-01' + ], + 'zero-int' => [ + 'value' => 0, + 'expected' => '1970-01-01' + ], + 'zero-date' => [ + 'value' => '0000-00-00', + 'expected' => '0000-00-00' + ], + 'zero-date-slashes' => [ + 'value' => '00/00/0000', + 'expected' => '00/00/0000' + ], + 'wrong-format-a' => [ + 'value' => '3/16/2003', + 'expected' => '3/16/2003', + ], + 'wrong-format-b' => [ + 'value' => '03-03-04', + 'expected' => '2003-03-04', + ], + ]; + } - $date = DBField::create_field('Date', null); - $this->assertNull($date->getValue(), 'NULL is set as NULL'); - - $date = DBField::create_field('Date', false); - $this->assertNull($date->getValue(), 'Boolean FALSE evaluates to NULL'); - - $date = DBField::create_field('Date', []); - $this->assertNull($date->getValue(), 'Empty array evaluates to NULL'); - - $date = DBField::create_field('Date', '0'); - $this->assertEquals('1970-01-01', $date->getValue(), 'Zero is UNIX epoch date'); - - $date = DBField::create_field('Date', 0); - $this->assertEquals('1970-01-01', $date->getValue(), 'Zero is UNIX epoch date'); - - $date = DBField::create_field('Date', '0000-00-00 00:00:00'); - $this->assertNull($date->getValue(), '0000-00-00 00:00:00 is set as NULL'); - - $date = DBField::create_field('Date', '00/00/0000'); - $this->assertNull($date->getValue(), '00/00/0000 is set as NULL'); + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected) + { + $field = new DBDate('MyField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); } public function testDayOfMonth() diff --git a/tests/php/ORM/DBDatetimeTest.php b/tests/php/ORM/DBDatetimeTest.php index b80addbe0..5dc825250 100644 --- a/tests/php/ORM/DBDatetimeTest.php +++ b/tests/php/ORM/DBDatetimeTest.php @@ -79,24 +79,6 @@ class DBDatetimeTest extends SapphireTest ); } - public function testSetNullAndZeroValues() - { - $date = DBDatetime::create_field('Datetime', ''); - $this->assertNull($date->getValue(), 'Empty string evaluates to NULL'); - - $date = DBDatetime::create_field('Datetime', null); - $this->assertNull($date->getValue(), 'NULL is set as NULL'); - - $date = DBDatetime::create_field('Datetime', false); - $this->assertNull($date->getValue(), 'Boolean FALSE evaluates to NULL'); - - $date = DBDatetime::create_field('Datetime', '0'); - $this->assertEquals('1970-01-01 00:00:00', $date->getValue(), 'String zero is UNIX epoch time'); - - $date = DBDatetime::create_field('Datetime', 0); - $this->assertEquals('1970-01-01 00:00:00', $date->getValue(), 'Numeric zero is UNIX epoch time'); - } - public function testExtendedDateTimes() { $date = DBDatetime::create_field('Datetime', '1600-10-10 15:32:24'); @@ -348,4 +330,50 @@ class DBDatetimeTest extends SapphireTest $after = (new DBDateTime())->setValue($timeAfter); $this->assertSame($expected, DBDatetime::getTimeBetween($before, $after)); } + + public static function provideSetValue(): array + { + return [ + 'int' => [ + 'value' => 800001234, + 'expected' => '1995-05-09 06:33:54', + ], + 'string-int' => [ + 'value' => '800001234', + 'expected' => '1995-05-09 06:33:54', + ], + 'zero' => [ + 'value' => 0, + 'expected' => '1970-01-01 00:00:00', + ], + 'zeroed' => [ + 'value' => '0000-00-00 00:00:00', + 'expected' => '0000-00-00 00:00:00', + ], + 'non-date-string' => [ + 'value' => 'fish', + 'expected' => 'fish', + ], + '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 DBDatetime('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 000000000..5a7b0068a --- /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 a44fbc57f..9190617e4 100644 --- a/tests/php/ORM/DBEnumTest.php +++ b/tests/php/ORM/DBEnumTest.php @@ -118,7 +118,7 @@ class DBEnumTest extends SapphireTest $obj2 = new FieldType\DBEnumTestObject(); $obj2->Colour = 'Purple'; - $obj2->write(); + $obj2->write(skipValidation: true); $this->assertEquals( ['Purple', 'Red'], @@ -141,4 +141,56 @@ class DBEnumTest extends SapphireTest $colourField->getEnumObsolete() ); } + + public static function provideSetValue(): array + { + return [ + 'string' => [ + 'value' => 'green', + 'expected' => 'green', + ], + 'string-not-in-set' => [ + 'value' => 'purple', + 'expected' => 'purple', + ], + 'int' => [ + 'value' => 123, + 'expected' => 123, + ], + 'empty-string' => [ + 'value' => '', + 'expected' => '', + ], + '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 b64cebf1b..e75474253 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\Dev\SapphireTest; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBYear; use PHPUnit\Framework\Attributes\DataProvider; +use SilverStripe\Core\ClassInfo; +use ReflectionClass; +use SilverStripe\Core\Validation\FieldValidation\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\EnumFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\IntFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\IpFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\LocaleFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\MultiEnumFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\StringFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\TimeFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\UrlFieldValidator; +use SilverStripe\Core\Validation\FieldValidation\YearFieldValidator; +use SilverStripe\ORM\FieldType\DBUrl; +use SilverStripe\ORM\FieldType\DBPolymorphicRelationAwareForeignKey; +use SilverStripe\ORM\FieldType\DBIp; +use SilverStripe\ORM\FieldType\DBEmail; +use SilverStripe\Core\Validation\FieldValidation\DatetimeFieldValidator; +use SilverStripe\ORM\FieldType\DBClassNameVarchar; /** * Tests for DBField objects. @@ -392,4 +418,179 @@ class DBFieldTest extends SapphireTest $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, + EnumFieldValidator::class, + ], + DBClassNameVarchar::class => [ + StringFieldValidator::class, + EnumFieldValidator::class, + ], + DBCurrency::class => [ + DecimalFieldValidator::class, + ], + DBDate::class => [ + DateFieldValidator::class, + ], + DBDatetime::class => [ + DatetimeFieldValidator::class, + ], + DBDecimal::class => [ + DecimalFieldValidator::class, + ], + DBDouble::class => [], + DBEmail::class => [ + StringFieldValidator::class, + EmailFieldValidator::class, + ], + DBEnum::class => [ + StringFieldValidator::class, + EnumFieldValidator::class, + ], + DBFloat::class => [], + DBForeignKey::class => [ + IntFieldValidator::class, + ], + DBHTMLText::class => [ + StringFieldValidator::class, + ], + DBHTMLVarchar::class => [ + StringFieldValidator::class, + ], + DBInt::class => [ + IntFieldValidator::class, + ], + DBIp::class => [ + StringFieldValidator::class, + IpFieldValidator::class, + ], + DBLocale::class => [ + StringFieldValidator::class, + LocaleFieldValidator::class, + ], + DBMoney::class => [ + CompositeFieldValidator::class, + ], + DBMultiEnum::class => [ + MultiEnumFieldValidator::class, + ], + DBPercentage::class => [ + DecimalFieldValidator::class, + ], + DBPolymorphicForeignKey::class => [], + DBPolymorphicRelationAwareForeignKey::class => [], + DBPrimaryKey::class => [ + IntFieldValidator::class, + ], + DBText::class => [ + StringFieldValidator::class, + ], + DBTime::class => [ + TimeFieldValidator::class, + ], + DBUrl::class => [ + StringFieldValidator::class, + UrlFieldValidator::class, + ], + DBVarchar::class => [ + StringFieldValidator::class, + ], + DBYear::class => [ + YearFieldValidator::class, + ], + ]; + $count = 0; + $classes = ClassInfo::subclassesFor(DBField::class); + foreach ($classes as $class) { + if (is_a($class, TestOnly::class, true)) { + continue; + } + if (!str_starts_with($class, 'SilverStripe\ORM\FieldType')) { + continue; + } + $reflector = new ReflectionClass($class); + if ($reflector->isAbstract()) { + continue; + } + if (!array_key_exists($class, $expectedFieldValidators)) { + throw new Exception("No field validator config found for $class"); + } + $expected = $expectedFieldValidators[$class]; + $method = new ReflectionMethod($class, 'getFieldValidators'); + $method->setAccessible(true); + $obj = new $class('MyField'); + $actual = array_map('get_class', $method->invoke($obj)); + $this->assertSame($expected, $actual, $class); + $count++; + } + // Assert that we have tested all classes e.g. namespace wasn't changed, no new classes were added + // that haven't been tested + $this->assertSame(29, $count); + } + + public function testSkipValidateIfNull() + { + $field = new DBInt('MyField'); + $field->setValue(null); + // assert value isn't getting changed on setValue(); + $this->assertNull($field->getValue()); + // assert that field validators were not called + $this->assertTrue($field->validate()->isValid()); + // assert that IntFieldValidator was applied to the field + $method = new ReflectionMethod(DBInt::class, 'getFieldValidators'); + $method->setAccessible(true); + $actual = array_map('get_class', $method->invoke($field)); + $this->assertSame([IntFieldValidator::class], $actual); + // assert that IntFieldValidator considers null as invalid + $validator = new IntFieldValidator('Test', null); + $this->assertFalse($validator->validate()->isValid()); + } } diff --git a/tests/php/ORM/DBForiegnKeyTest.php b/tests/php/ORM/DBForiegnKeyTest.php new file mode 100644 index 000000000..d16f973b3 --- /dev/null +++ b/tests/php/ORM/DBForiegnKeyTest.php @@ -0,0 +1,44 @@ + [ + 'value' => 2, + 'expected' => 2, + ], + 'string' => [ + 'value' => '2', + 'expected' => 2, + ], + 'zero' => [ + 'value' => 0, + 'expected' => 0, + ], + 'blank-string' => [ + 'value' => '', + 'expected' => 0, + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBForeignKey('TestField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } +} diff --git a/tests/php/ORM/DBIntTest.php b/tests/php/ORM/DBIntTest.php index 554d80232..a2d9d249c 100644 --- a/tests/php/ORM/DBIntTest.php +++ b/tests/php/ORM/DBIntTest.php @@ -4,15 +4,82 @@ namespace SilverStripe\ORM\Tests; use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\FieldType\DBInt; +use PHPUnit\Framework\Attributes\DataProvider; class DBIntTest extends SapphireTest { - public function testGetValueCastToInt() + public function testDefaultValue(): void { - $field = DBInt::create('MyField'); - $field->setValue(3); - $this->assertSame(3, $field->getValue()); - $field->setValue('3'); - $this->assertSame(3, $field->getValue()); + $field = new DBInt('MyField'); + $this->assertSame(0, $field->getValue()); + } + + public static function provideSetValue(): array + { + return [ + 'int' => [ + 'value' => 3, + 'expected' => 3, + ], + 'string-int' => [ + 'value' => '3', + 'expected' => 3, + ], + 'negative-int' => [ + 'value' => -3, + 'expected' => -3, + ], + 'negative-string-int' => [ + 'value' => '-3', + 'expected' => -3, + ], + '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 000000000..813502654 --- /dev/null +++ b/tests/php/ORM/DBMultiEnumTest.php @@ -0,0 +1,44 @@ + [ + 'value' => ['Red', 'Green'], + 'expected' => ['Red', 'Green'], + ], + 'string' => [ + 'value' => 'Red,Green', + 'expected' => ['Red', 'Green'], + ], + 'string-non-existant-value' => [ + 'value' => 'Red,Green,Purple', + 'expected' => ['Red', 'Green', 'Purple'], + ], + 'empty-string' => [ + 'value' => '', + 'expected' => [''], + ], + 'null' => [ + 'value' => null, + 'expected' => [''], + ], + ]; + } + + #[DataProvider('provideGetValueForValidation')] + public function testGetValueForValidation(mixed $value, array $expected): void + { + $obj = new DBMultiEnum('TestField', ['Red', 'Green', 'Blue']); + $obj->setValue($value); + $this->assertSame($expected, $obj->getValueForValidation()); + } +} diff --git a/tests/php/ORM/DBStringTest.php b/tests/php/ORM/DBStringTest.php index 81a4d3816..d9513d47a 100644 --- a/tests/php/ORM/DBStringTest.php +++ b/tests/php/ORM/DBStringTest.php @@ -7,6 +7,7 @@ use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBString; use SilverStripe\Dev\SapphireTest; use SilverStripe\ORM\Tests\DBStringTest\MyStringField; +use PHPUnit\Framework\Attributes\DataProvider; class DBStringTest extends SapphireTest { diff --git a/tests/php/ORM/DBTimeTest.php b/tests/php/ORM/DBTimeTest.php index 68bd91646..7c26a7b17 100644 --- a/tests/php/ORM/DBTimeTest.php +++ b/tests/php/ORM/DBTimeTest.php @@ -17,33 +17,78 @@ class DBTimeTest extends SapphireTest i18n::set_locale('en_NZ'); } - public static function dataTestParse() + public static function provideSetValue() { return [ - // Test am-pm conversion - ['11:01 pm', '23:01:00'], - ['11:01 am', '11:01:00'], - ['12:01 pm', '12:01:00'], - ['12:01 am', '00:01:00'], - // Test seconds - ['11:01.01 pm', '23:01:01'], - ['12:01.01', '12:01:01'], + 'time-11pm' => [ + 'value' => '11:01 pm', + 'expected' => '23:01:00' + ], + 'time-11am' => [ + 'value' => '11:01 am', + 'expected' => '11:01:00' + ], + 'time-12am' => [ + 'value' => '12:01 am', + 'expected' => '00:01:00' + ], + 'time-12pm' => [ + 'value' => '12:01 pm', + 'expected' => '12:01:00' + ], + 'time-11pm-seconds' => [ + 'value' => '11:01.01 pm', + 'expected' => '23:01:01' + ], + 'time-12-seconds' => [ + 'value' => '12:01.01', + 'expected' => '12:01:01' + ], + 'wrong-format-works' => [ + 'value' => '12.34.56', + 'expected' => '12:34:56', + ], + 'int' => [ + 'value' => 6789, + 'expected' => '01:53:09' + ], + 'int-string' => [ + 'value' => '6789', + 'expected' => '01:53:09' + ], + 'zero-string' => [ + 'value' => '0', + 'expected' => '00:00:00' + ], + 'zero-int' => [ + 'value' => 0, + 'expected' => '00:00:00' + ], + 'blank-string' => [ + 'value' => '', + 'expected' => '' + ], + 'null' => [ + 'value' => null, + 'expected' => null + ], + 'false' => [ + 'value' => false, + 'expected' => false + ], + 'empty-array' => [ + 'value' => [], + 'expected' => [] + ], ]; } - /** - * @param string $input - * @param string $expected - */ - #[DataProvider('dataTestParse')] - public function testParse($input, $expected) + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected) { - $time = DBField::create_field('Time', $input); - $this->assertEquals( - $expected, - $time->getValue(), - "Date parsed from {$input} should be {$expected}" - ); + $field = new DBTime('MyField'); + $field->setValue($value); + $this->assertSame($expected, $field->getValue()); } public function testNice() diff --git a/tests/php/ORM/DBYearTest.php b/tests/php/ORM/DBYearTest.php index 79632bae3..710745185 100755 --- a/tests/php/ORM/DBYearTest.php +++ b/tests/php/ORM/DBYearTest.php @@ -5,6 +5,7 @@ namespace SilverStripe\ORM\Tests; use SilverStripe\Forms\DropdownField; use SilverStripe\ORM\FieldType\DBYear; use SilverStripe\Dev\SapphireTest; +use PHPUnit\Framework\Attributes\DataProvider; class DBYearTest extends SapphireTest { @@ -18,15 +19,15 @@ class DBYearTest extends SapphireTest $field = $year->scaffoldFormField("YearTest"); $this->assertEquals(DropdownField::class, get_class($field)); - //This should be a list of years from the current one, counting down to 1900 + //This should be a list of years from the current one, counting down to 1901 $source = $field->getSource(); $lastValue = end($source); $lastKey = key($source ?? []); - //Keys and values should be the same - and the last one should be 1900 - $this->assertEquals(1900, $lastValue); - $this->assertEquals(1900, $lastKey); + //Keys and values should be the same - and the last one should be 1901 + $this->assertEquals(1901, $lastValue); + $this->assertEquals(1901, $lastKey); } public function testScaffoldFormFieldLast() @@ -43,4 +44,98 @@ class DBYearTest extends SapphireTest $this->assertEquals($currentYear, $firstValue); $this->assertEquals($currentYear, $firstKey); } + + public static function provideSetValue(): array + { + return [ + '4-int' => [ + 'value' => 2024, + 'expected' => 2024, + ], + '2-int' => [ + 'value' => 24, + 'expected' => 2024, + ], + '0-int' => [ + 'value' => 0, + 'expected' => 0, + ], + '4-string' => [ + 'value' => '2024', + 'expected' => 2024, + ], + '2-string' => [ + 'value' => '24', + 'expected' => 2024, + ], + '0-string' => [ + 'value' => '0', + 'expected' => 0, + ], + '00-string' => [ + 'value' => '00', + 'expected' => 2000, + ], + '0000-string' => [ + 'value' => '0000', + 'expected' => 0, + ], + '4-int-low' => [ + 'value' => 1900, + 'expected' => 1900, + ], + '4-int-low' => [ + 'value' => 2156, + 'expected' => 2156, + ], + '4-string-low' => [ + 'value' => '1900', + 'expected' => 1900, + ], + '4-string-low' => [ + 'value' => '2156', + 'expected' => 2156, + ], + 'int-negative' => [ + 'value' => -2024, + 'expected' => -2024, + ], + 'string-negative' => [ + 'value' => '-2024', + 'expected' => '-2024', + ], + 'float' => [ + 'value' => 2024.0, + 'expected' => 2024.0, + ], + 'string-float' => [ + 'value' => '2024.0', + 'expected' => '2024.0', + ], + 'null' => [ + 'value' => null, + 'expected' => null, + ], + 'true' => [ + 'value' => true, + 'expected' => true, + ], + 'false' => [ + 'value' => false, + 'expected' => false, + ], + 'array' => [ + 'value' => [], + 'expected' => [], + ], + ]; + } + + #[DataProvider('provideSetValue')] + public function testSetValue(mixed $value, mixed $expected): void + { + $field = new DBYear('MyField'); + $result = $field->setValue($value); + $this->assertSame($expected, $field->getValue()); + } } diff --git a/tests/php/ORM/DecimalTest.php b/tests/php/ORM/DecimalTest.php index 0705da109..0fab0a8ad 100644 --- a/tests/php/ORM/DecimalTest.php +++ b/tests/php/ORM/DecimalTest.php @@ -24,22 +24,23 @@ class DecimalTest extends SapphireTest { 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 @@ class DecimalTest extends SapphireTest 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 b9c70d71c..6d2723c95 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")', ]; }