diff --git a/.gitignore b/.gitignore index 783d717..3258f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ vendor/ .idea/ coverage.xml +clover.xml package.xml coverage-html/ .phpunit/ /.php-cs-fixer.cache +tests/.DS_Store diff --git a/clover.xml b/clover.xml deleted file mode 100644 index 26f192e..0000000 --- a/clover.xml +++ /dev/null @@ -1,1664 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/composer.json b/composer.json index a09f12f..0a5b607 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ } }, "scripts": { - "ci-test": "XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=ci --configuration phpunit.xml", + "ci-test": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --testsuite=ci --configuration phpunit.xml", + "unit-test": "XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit --configuration phpunit.xml", "phpstan": "vendor/bin/phpstan analyse --configuration phpstan.neon --memory-limit=256M" } } diff --git a/src/Concerns/BaseData.php b/src/Concerns/BaseData.php index 2e9b0d1..7d42eeb 100644 --- a/src/Concerns/BaseData.php +++ b/src/Concerns/BaseData.php @@ -9,7 +9,6 @@ use Nuxtifyts\PhpDto\Normalizers\Concerns\HasNormalizers; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipeline; use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable; -use ReflectionClass; use Throwable; trait BaseData diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index c7cde2e..ac52b55 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -139,7 +139,8 @@ private function syncClassAttributes(): void /** * @throws ReflectionException * - * @return T + * @return Data + * @phpstan-return T */ public function newInstanceWithoutConstructor(): mixed { @@ -149,7 +150,8 @@ public function newInstanceWithoutConstructor(): mixed /** * @throws ReflectionException * - * @return T + * @return Data + * @phpstan-return T */ public function newInstanceWithConstructorCall(mixed ...$args): mixed { diff --git a/src/Contracts/ValidateableData.php b/src/Contracts/ValidateableData.php new file mode 100644 index 0000000..3954688 --- /dev/null +++ b/src/Contracts/ValidateableData.php @@ -0,0 +1,32 @@ + $data + * + * @throws DataValidationException + */ + public static function validate(array $data): void; + + /** + * @param array $data + * + * @throws DataValidationException + */ + public static function validateAndCreate(array $data): static; + + /** + * @return true|array> + */ + public function isValid(): true|array; + + /** + * @return array + */ + public static function validationRules(): array; +} diff --git a/src/Enums/Property/Type.php b/src/Enums/Property/Type.php index 3953d28..ff39a8a 100644 --- a/src/Enums/Property/Type.php +++ b/src/Enums/Property/Type.php @@ -20,4 +20,10 @@ enum Type: string self::BOOLEAN, self::STRING, ]; + + /** @var list */ + public const array NUMERIC_TYPES = [ + self::FLOAT, + self::INT, + ]; } diff --git a/src/Exceptions/DataValidationException.php b/src/Exceptions/DataValidationException.php new file mode 100644 index 0000000..269a395 --- /dev/null +++ b/src/Exceptions/DataValidationException.php @@ -0,0 +1,9 @@ + $array - * @param string $key * @param array $default * * @return array @@ -18,6 +20,130 @@ public static function getArray(array $array, string $key, array $default = []): return is_array($value) ? $value : $default; } + /** + * @param array $array + */ + public static function getStringOrNull(array $array, string $key): ?string + { + $value = $array[$key] ?? null; + + return is_string($value) ? $value : null; + } + + /** + * @param array $array + */ + public static function getString(array $array, string $key, string $default = ''): string + { + return self::getStringOrNull($array, $key) ?? $default; + } + + /** + * @param array $array + */ + public static function getIntegerOrNull(array $array, string $key): ?int + { + $value = $array[$key] ?? null; + + return is_int($value) ? $value : null; + } + + /** + * @param array $array + */ + public static function getInteger(array $array, string $key, int $default = 0): int + { + return self::getIntegerOrNull($array, $key) ?? $default; + } + + /** + * @param array $array + */ + public static function getFloatOrNull(array $array, string $key): ?float + { + $value = $array[$key] ?? null; + + return is_float($value) ? $value : null; + } + + /** + * @param array $array + */ + public static function getFloat(array $array, string $key, float $default = 0.0): float + { + return self::getFloatOrNull($array, $key) ?? $default; + } + + /** + * @param array $array + */ + public static function getBooleanOrNull(array $array, string $key): ?bool + { + $value = $array[$key] ?? null; + + return is_bool($value) ? $value : null; + } + + /** + * @param array $array + */ + public static function getBoolean(array $array, string $key, bool $default = false): bool + { + return self::getBooleanOrNull($array, $key) ?? $default; + } + + /** + * @template T of BackedEnum + * + * @param array $array + * @param class-string $enumClass + * @param ?T $default + * + * @return ?T + */ + public static function getBackedEnumOrNull( + array $array, + string $key, + string $enumClass, + ?BackedEnum $default = null + ): ?BackedEnum { + $value = $array[$key] ?? null; + + if ($value instanceof $enumClass) { + return $value; + } else if ( + (is_string($value) || is_integer($value)) + && $resolvedValue = $enumClass::tryFrom($value) + ) { + return $resolvedValue; + } + + return is_null($default) + ? null + : ($default instanceof $enumClass + ? $default + : throw new InvalidArgumentException('Default value must be an instance of ' . $enumClass) + ); + } + + /** + * @template T of BackedEnum + * + * @param array $array + * @param class-string $enumClass + * @param T $default + * + * @return T + */ + public static function getBackedEnum( + array $array, + string $key, + string $enumClass, + BackedEnum $default + ): BackedEnum { + return self::getBackedEnumOrNull($array, $key, $enumClass, $default) ?? $default; + } + /** * @param array $array * @param class-string $classString @@ -30,4 +156,40 @@ public static function isArrayOfClassStrings(array $array, string $classString): && is_subclass_of($value, $classString) ); } + + /** + * @param array $array + * + * @return ($preserveKeys is true ? array : list) + */ + public static function flatten(array $array, float $depth = INF, bool $preserveKeys = true): array + { + $result = []; + + foreach ($array as $key => $item) { + $item = $item instanceof Collection ? $item->all() : $item; + + if (! is_array($item)) { + if ($preserveKeys) { + $result[$key] = $item; + } else { + $result[] = $item; + } + } else { + $values = $depth === 1.0 + ? $item + : self::flatten($item, $depth - 1, $preserveKeys); + + foreach ($values as $subKey => $value) { + if ($preserveKeys) { + $result[$subKey] = $value; + } else { + $result[] = $value; + } + } + } + } + + return $result; + } } diff --git a/src/Support/Collection.php b/src/Support/Collection.php new file mode 100644 index 0000000..47369c1 --- /dev/null +++ b/src/Support/Collection.php @@ -0,0 +1,117 @@ + */ + protected array $items = []; + + /** + * @param array $items + */ + public function __construct(array $items = []) + { + $this->items = $items; + } + + /** + * @param TValue $item + * + * @return self + */ + public function push(mixed $item): self + { + $this->items[] = $item; + return $this; + } + + /** + * @param TKey $key + * @param TValue $value + * + * @return self + */ + public function put(mixed $key, mixed $value): self + { + $this->items[$key] = $value; + return $this; + } + + /** + * @param ?callable(TValue $item): bool $callable + * + * @return ?TValue + */ + public function first(?callable $callable = null): mixed + { + return is_null($callable) + ? reset($this->items) ?: null + : array_find($this->items, $callable); + } + + /** + * @template TNewValue of mixed + * @param callable(TValue $item): TNewValue $callable + * + * @return self + */ + public function map(callable $callable): self + { + return new self(array_map($callable, $this->items)); + } + + /** + * @return ($preserveKeys is true ? Collection : Collection) + */ + public function collapse(bool $preserveKeys = false): self + { + return $this->flatten(1, $preserveKeys); + } + + /** + * @return ($preserveKeys is true ? Collection : Collection) + */ + public function flatten(float $depth = INF, bool $preserveKeys = true): self + { + return new self(Arr::flatten($this->items, $depth, $preserveKeys)); + } + + public function isNotEmpty(): bool + { + return !empty($this->items); + } + + public function isEmpty(): bool + { + return !$this->isNotEmpty(); + } + + /** + * @param callable(TValue $item): bool $callable + */ + public function every(callable $callable): bool + { + return array_all($this->items, $callable); + } + + /** + * @param callable(TValue $item): bool $callable + */ + public function some(callable $callable): bool + { + return array_any($this->items, $callable); + } + + /** + * @return array + */ + public function all(): array + { + return $this->items; + } +} diff --git a/src/Validation/Contracts/RuleEvaluator.php b/src/Validation/Contracts/RuleEvaluator.php new file mode 100644 index 0000000..df678b6 --- /dev/null +++ b/src/Validation/Contracts/RuleEvaluator.php @@ -0,0 +1,8 @@ + */ + public Collection $rules { get; } + + /** + * @throws ValidationRuleException + */ + public function addRule(RuleEvaluator $rule): static; +} diff --git a/src/Validation/Logic/AndRule.php b/src/Validation/Logic/AndRule.php new file mode 100644 index 0000000..f0cf592 --- /dev/null +++ b/src/Validation/Logic/AndRule.php @@ -0,0 +1,25 @@ +rules->every( + static fn (RuleEvaluator $rule) => $rule->evaluate($value) + ); + } + + public function validationMessageTree(): array + { + return [ + 'and' => $this->rules + ->map(self::resolveValidationMessages(...)) + ->collapse(preserveKeys: true) + ->all() + ]; + } +} diff --git a/src/Validation/Logic/LogicalRule.php b/src/Validation/Logic/LogicalRule.php new file mode 100644 index 0000000..e770080 --- /dev/null +++ b/src/Validation/Logic/LogicalRule.php @@ -0,0 +1,51 @@ + */ + protected Collection $_rules; + + /** @var Collection */ + public Collection $rules { + get { + return $this->_rules; + } + } + + public function __construct() + { + $this->_rules = new Collection(); + } + + public function addRule(RuleEvaluator $rule): static + { + $this->rules->push($rule); + return $this; + } + + /** + * @return array + */ + abstract public function validationMessageTree(): array; + + /** + * @return ?array + */ + protected static function resolveValidationMessages(?RuleEvaluator $rule): ?array + { + return match (true) { + $rule instanceof LogicalRule => $rule->validationMessageTree(), + $rule instanceof ValidationRule => [ + $rule->name => $rule->validationMessage() + ], + default => null + }; + } +} diff --git a/src/Validation/Logic/OrRule.php b/src/Validation/Logic/OrRule.php new file mode 100644 index 0000000..420dd73 --- /dev/null +++ b/src/Validation/Logic/OrRule.php @@ -0,0 +1,25 @@ +rules->some( + static fn (RuleEvaluator $rule) => $rule->evaluate($value) + ); + } + + public function validationMessageTree(): array + { + return [ + 'or' => $this->rules + ->map(self::resolveValidationMessages(...)) + ->collapse(preserveKeys: true) + ->all() + ]; + } +} diff --git a/src/Validation/Logic/SingularRule.php b/src/Validation/Logic/SingularRule.php new file mode 100644 index 0000000..b0da119 --- /dev/null +++ b/src/Validation/Logic/SingularRule.php @@ -0,0 +1,36 @@ +rules->isNotEmpty()) { + throw LogicalRuleException::unableToCreateRule('SingularRule can only have one rule'); + } + + $this->rules->push($rule); + return $this; + } + + public function evaluate(mixed $value): bool + { + return (bool) $this->rules->first()?->evaluate($value); + } + + public function validationMessageTree(): array + { + return [ + 'singular' => self::resolveValidationMessages($this->rules->first()) + ]; + } +} diff --git a/src/Validation/Logic/TruthyRule.php b/src/Validation/Logic/TruthyRule.php new file mode 100644 index 0000000..4d1d07d --- /dev/null +++ b/src/Validation/Logic/TruthyRule.php @@ -0,0 +1,29 @@ + */ + public protected(set) string $backedEnumClass; + + /** + * @var ?array + */ + public protected(set) ?array $allowedValues = null; + + public string $name { + get { + return 'backed_enum'; + } + } + + public function evaluate(mixed $value): bool + { + if ($value instanceof $this->backedEnumClass) { + /** @var BackedEnum $value */ + $resolvedValue = $value; + } else if (is_string($value) || is_integer($value)) { + $resolvedValue = $this->backedEnumClass::tryFrom($value); + } else { + return false; + } + + return !!$resolvedValue + && ( + is_null($this->allowedValues) + || in_array($resolvedValue->value, array_column($this->allowedValues, 'value')) + ); + } + + /** + * @param ?array $parameters + * + * @throws ValidationRuleException + */ + public static function make(?array $parameters = null): self + { + $instance = new self(); + + $backedEnumClass = $parameters['backedEnumClass'] ?? null; + + if ( + !is_string($backedEnumClass) + || !enum_exists($backedEnumClass) + || !is_subclass_of($backedEnumClass, BackedEnum::class) + ) { + throw ValidationRuleException::invalidParameters(); + } + + $instance->backedEnumClass = $backedEnumClass; + $instance->allowedValues = array_filter( + array_map(static fn (mixed $value) => + ($value instanceof $instance->backedEnumClass) + ? $value + : null, + Arr::getArray($parameters ?? [], 'allowedValues') + ) + ) ?: null; + + return $instance; + } + + public function validationMessage(): string + { + if ($this->allowedValues) { + $allowedValues = implode( + ', ', + array_map(static fn (BackedEnum $value) => $value->value, $this->allowedValues) + ); + + return "The :attribute field must be one of the following values: $allowedValues."; + } else { + return 'The :attribute field is invalid.'; + } + } +} diff --git a/src/Validation/Rules/Concerns/MinMaxValues.php b/src/Validation/Rules/Concerns/MinMaxValues.php new file mode 100644 index 0000000..effcc5f --- /dev/null +++ b/src/Validation/Rules/Concerns/MinMaxValues.php @@ -0,0 +1,49 @@ + $parameters + * @param Type::INT | Type::FLOAT $type + * + * @return array{ 0: int|null|float, 1: int|null|float } + * + * @throws ValidationRuleException + */ + protected static function getMinMaxValues( + ?array $parameters = null, + string $minKey = 'min', + string $maxKey = 'max', + Type $type = Type::INT + ): array { + $arrFunc = match ($type) { + Type::INT => 'getIntegerOrNull', + Type::FLOAT => 'getFloatOrNull', + }; + + $min = match ($type) { + Type::INT => Arr::getIntegerOrNull($parameters ?? [], $minKey), + Type::FLOAT => Arr::getFloatOrNull($parameters ?? [], $minKey), + }; + + $max = match ($type) { + Type::INT => Arr::getIntegerOrNull($parameters ?? [], $maxKey), + Type::FLOAT => Arr::getFloatOrNull($parameters ?? [], $maxKey), + }; + + if ( + (!is_null($min) && $min < 0) + || (!is_null($max) && ($max <= 0 || $min > $max)) + ) { + throw ValidationRuleException::invalidParameters(); + } + + return [$min, $max]; + } +} diff --git a/src/Validation/Rules/DateRule.php b/src/Validation/Rules/DateRule.php new file mode 100644 index 0000000..4897457 --- /dev/null +++ b/src/Validation/Rules/DateRule.php @@ -0,0 +1,61 @@ + + */ + protected array $formats = []; + + public string $name { + get { + return 'date'; + } + } + + public function evaluate(mixed $value): bool + { + return empty($this->formats) + ? is_string($value) && strtotime($value) !== false + : is_string($value) && array_any( + $this->formats, + static fn (string $format): bool => (bool) date_create_from_format($format, $value) + ); + } + + /** + * @param ?array $parameters + * + * @throws ValidationRuleException + */ + public static function make(?array $parameters = null): self + { + $instance = new self(); + + $formats = Arr::getArray($parameters ?? [], 'formats', []); + + if (array_any( + $formats, + static fn (mixed $format): bool => !is_string($format) + )) { + throw ValidationRuleException::invalidParameters(); + } + + /** @var array $formats */ + $instance->formats = array_values($formats); + + return $instance; + } + + public function validationMessage(): string + { + return empty($this->formats) + ? 'The :attribute field must be a valid date.' + : 'The :attribute field must be a valid date in one of the following formats: ' . implode(', ', $this->formats); + } +} diff --git a/src/Validation/Rules/EmailRule.php b/src/Validation/Rules/EmailRule.php new file mode 100644 index 0000000..d9c811f --- /dev/null +++ b/src/Validation/Rules/EmailRule.php @@ -0,0 +1,34 @@ + $parameters + */ + #[Override] + public static function make(?array $parameters = null): self + { + $instance = new self(); + + $instance->pattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; + + return $instance; + } + + #[Override] + public function validationMessage(): string + { + return 'The :attribute field must be a valid email address.'; + } +} diff --git a/src/Validation/Rules/NullableRule.php b/src/Validation/Rules/NullableRule.php new file mode 100644 index 0000000..b0b5e20 --- /dev/null +++ b/src/Validation/Rules/NullableRule.php @@ -0,0 +1,30 @@ + $parameters + */ + public static function make(?array $parameters = null): self + { + return new self(); + } + + public function validationMessage(): string + { + return 'The :attribute field must be nullable.'; + } +} diff --git a/src/Validation/Rules/NumericRule.php b/src/Validation/Rules/NumericRule.php new file mode 100644 index 0000000..07f42d3 --- /dev/null +++ b/src/Validation/Rules/NumericRule.php @@ -0,0 +1,79 @@ +type === Type::INT && !is_int($value)) + || ($this->type === Type::FLOAT && !is_float($value)) + || (!is_null($this->min) && $value < $this->min) + || (!is_null($this->max) && $value > $this->max) + ) { + return false; + } + + return true; + } + + /** + * @param ?array $parameters + * + * @throws ValidationRuleException + */ + public static function make(?array $parameters = null): self + { + $instance = new self(); + + $numericType = Arr::getBackedEnum( + $parameters ?? [], + 'type', + Type::class, + Type::INT + ); + + if (!in_array( + $numericType->value, + array_column(Type::NUMERIC_TYPES, 'value')) + ) { + throw ValidationRuleException::invalidParameters(); + } + + /** @var Type::INT | Type::FLOAT $numericType */ + $instance->type = $numericType; + + [$instance->min, $instance->max] = self::getMinMaxValues($parameters, type: $numericType); + + return $instance; + } + + public function validationMessage(): string + { + return match($this->type) { + Type::INT => 'The :attribute field must be a valid integer.', + Type::FLOAT => 'The :attribute field must be a valid float.', + }; + } +} diff --git a/src/Validation/Rules/RegexRule.php b/src/Validation/Rules/RegexRule.php new file mode 100644 index 0000000..c3038c8 --- /dev/null +++ b/src/Validation/Rules/RegexRule.php @@ -0,0 +1,57 @@ +pattern, + subject: (string) $value, + flags: $this->flags, + offset: $this->offset + ); + } + + /** + * @param ?array $parameters + * + * @throws ValidationRuleException + */ + public static function make(?array $parameters = null): self + { + $instance = new self(); + + $pattern = $parameters['pattern'] ?? null; + + if (!is_string($pattern) || @preg_match($pattern, '') === false) { + throw ValidationRuleException::invalidParameters(); + } + + $instance->pattern = $pattern; + + return $instance; + } + + public function validationMessage(): string + { + return 'The :attribute field does not match the required pattern.'; + } +} diff --git a/src/Validation/Rules/RequiredRule.php b/src/Validation/Rules/RequiredRule.php new file mode 100644 index 0000000..f85e993 --- /dev/null +++ b/src/Validation/Rules/RequiredRule.php @@ -0,0 +1,30 @@ + $parameters + */ + public static function make(?array $parameters = null): self + { + return new self(); + } + + public function validationMessage(): string + { + return 'The :attribute field is required.'; + } +} diff --git a/src/Validation/Rules/StringRule.php b/src/Validation/Rules/StringRule.php new file mode 100644 index 0000000..a7114eb --- /dev/null +++ b/src/Validation/Rules/StringRule.php @@ -0,0 +1,65 @@ + */ + protected const array TYPES = [ + self::TYPE_STRING, + self::TYPE_ALPHA, + ]; + + public string $name { + get { + return 'string'; + } + } + + /** + * @param ?array $parameters + * + * @throws ValidationRuleException + */ + #[Override] + public static function make(?array $parameters = null): self + { + $instance = new self(); + + $strType = Arr::getString($parameters ?? [], 'type', self::TYPE_STRING); + + if (!in_array($strType, self::TYPES)) { + throw ValidationRuleException::invalidParameters(); + } + + /** @var 'string' | 'alpha' $strType */ + $strPattern = match ($strType) { + self::TYPE_STRING => '.', + self::TYPE_ALPHA => '[a-zA-Z0-9]' + }; + + [$minLen, $maxLen] = self::getMinMaxValues($parameters, 'minLen', 'maxLen'); + + $lengthPattern = is_null($minLen) ? '{0,' : '{' . $minLen . ','; + $lengthPattern .= is_null($maxLen) ? '}' : $maxLen . '}'; + + $instance->pattern = '/^' . $strPattern . $lengthPattern . '$/'; + + return $instance; + } + + #[Override] + public function validationMessage(): string + { + return 'The :attribute field must be a valid string.'; + } +} diff --git a/src/Validation/Rules/ValidationRule.php b/src/Validation/Rules/ValidationRule.php new file mode 100644 index 0000000..1bd41d8 --- /dev/null +++ b/src/Validation/Rules/ValidationRule.php @@ -0,0 +1,20 @@ + $parameters + * + * @throws ValidationRuleException + */ + public static function make(?array $parameters = null): self; + + public function validationMessage(): string; +} diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php new file mode 100644 index 0000000..bd3c57d --- /dev/null +++ b/src/Validation/Validator.php @@ -0,0 +1,8 @@ + [ + 'getString', + [ + 'array' => ['key' => 1], + 'key' => 'key', + ], + '', + ], + 'get string existing key, valid value' => [ + 'getString', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + 'value', + ], + 'get string non-existing key' => [ + 'getString', + [ + 'array' => ['key' => 'value'], + 'key' => 'nonExistingKey', + ], + '', + ], + 'get string or null existing key, invalid value' => [ + 'getStringOrNull', + [ + 'array' => ['key' => 1], + 'key' => 'key', + ], + null, + ], + 'get string or null existing key, valid value' => [ + 'getStringOrNull', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + 'value', + ], + 'get string or null non-existing key' => [ + 'getStringOrNull', + [ + 'array' => ['key' => 'value'], + 'key' => 'nonExistingKey', + ], + null, + ], + 'get integer existing key, invalid value' => [ + 'getInteger', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + 0, + ], + 'get integer existing key, valid value' => [ + 'getInteger', + [ + 'array' => ['key' => 1], + 'key' => 'key', + ], + 1, + ], + 'get integer non-existing key' => [ + 'getInteger', + [ + 'array' => ['key' => 1], + 'key' => 'nonExistingKey', + ], + 0, + ], + 'get integer or null existing key, invalid value' => [ + 'getIntegerOrNull', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + null, + ], + 'get integer or null existing key, valid value' => [ + 'getIntegerOrNull', + [ + 'array' => ['key' => 1], + 'key' => 'key', + ], + 1, + ], + 'get integer or null non-existing key' => [ + 'getIntegerOrNull', + [ + 'array' => ['key' => 1], + 'key' => 'nonExistingKey', + ], + null, + ], + 'get float existing key, invalid value' => [ + 'getFloat', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + 0.0, + ], + 'get float existing key, valid value' => [ + 'getFloat', + [ + 'array' => ['key' => 1.1], + 'key' => 'key', + ], + 1.1, + ], + 'get float non-existing key' => [ + 'getFloat', + [ + 'array' => ['key' => 1.1], + 'key' => 'nonExistingKey', + ], + 0.0, + ], + 'get float or null existing key, invalid value' => [ + 'getFloatOrNull', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + null, + ], + 'get float or null existing key, valid value' => [ + 'getFloatOrNull', + [ + 'array' => ['key' => 1.1], + 'key' => 'key', + ], + 1.1, + ], + 'get float or null non-existing key' => [ + 'getFloatOrNull', + [ + 'array' => ['key' => 1.1], + 'key' => 'nonExistingKey', + ], + null, + ], + 'get boolean existing key, invalid value' => [ + 'getBoolean', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + false, + ], + 'get boolean existing key, valid value' => [ + 'getBoolean', + [ + 'array' => ['key' => true], + 'key' => 'key', + ], + true, + ], + 'get boolean non-existing key' => [ + 'getBoolean', + [ + 'array' => ['key' => true], + 'key' => 'nonExistingKey', + ], + false, + ], + 'get boolean or null existing key, invalid value' => [ + 'getBooleanOrNull', + [ + 'array' => ['key' => 'value'], + 'key' => 'key', + ], + null, + ], + 'get boolean or null existing key, valid value' => [ + 'getBooleanOrNull', + [ + 'array' => ['key' => true], + 'key' => 'key', + ], + true, + ], + 'get boolean or null non-existing key' => [ + 'getBooleanOrNull', + [ + 'array' => ['key' => true], + 'key' => 'nonExistingKey', + ], + null, + ], + 'get backed enum or null, invalid value' => [ + 'getBackedEnumOrNull', + [ + 'array' => ['key' => 'invalid'], + 'key' => 'key', + 'enumClass' => YesNoBackedEnum::class, + ], + null + ], + 'get backed enum or null, invalid value default provided' => [ + 'getBackedEnumOrNull', + [ + 'array' => ['key' => 'invalid'], + 'key' => 'key', + 'enumClass' => YesNoBackedEnum::class, + 'default' => YesNoBackedEnum::NO, + ], + YesNoBackedEnum::NO + ], + 'get backed enum or null, valid backed enum value' => [ + 'getBackedEnumOrNull', + [ + 'array' => ['key' => YesNoBackedEnum::YES], + 'key' => 'key', + 'enumClass' => YesNoBackedEnum::class, + ], + YesNoBackedEnum::YES + ], + 'get backed enum or null, valid string value' => [ + 'getBackedEnumOrNull', + [ + 'array' => ['key' => 'yes'], + 'key' => 'key', + 'enumClass' => YesNoBackedEnum::class, + ], + YesNoBackedEnum::YES + ], + 'get backed enum, invalid value' => [ + 'getBackedEnum', + [ + 'array' => ['key' => 'invalid'], + 'key' => 'key', + 'enumClass' => YesNoBackedEnum::class, + 'default' => YesNoBackedEnum::NO, + ], + YesNoBackedEnum::NO + ], + 'get backed enum, valid backed enum value' => [ + 'getBackedEnum', + [ + 'array' => ['key' => YesNoBackedEnum::YES], + 'key' => 'key', + 'enumClass' => YesNoBackedEnum::class, + 'default' => YesNoBackedEnum::NO, + ], + YesNoBackedEnum::YES + ], + 'get backed enum, valid string value' => [ + 'getBackedEnum', + [ + 'array' => ['key' => 'yes'], + 'key' => 'key', + 'enumClass' => YesNoBackedEnum::class, + 'default' => YesNoBackedEnum::NO, + ], + YesNoBackedEnum::YES + ], ]; } + #[Test] + public function get_backed_enum_or_null_will_throw_an_exception_if_default_value_is_invalid(): void + { + self::expectException(InvalidArgumentException::class); + + Arr::getBackedEnumOrNull( + ['key' => 'invalid'], + 'key', + YesNoBackedEnum::class, + ColorsBackedEnum::RED + ); + } + /** * @return array */ @@ -96,4 +375,124 @@ public static function is_array_of_class_strings_provider(): array ], ]; } + + /** + * @return array + */ + public static function flatten_provider(): array + { + return [ + 'flatten, empty array' => [ + 'flatten', + [ + 'array' => [], + ], + [] + ], + 'flatten array, one depth' => [ + 'flatten', + [ + 'array' => [ + 'a' => [ + 'a1' => 1.1, + 'a2' => 1.2 + ], + 'b' => 2, + ], + ], + [ + 'a1' => 1.1, + 'a2' => 1.2, + 'b' => 2 + ] + ], + 'flatten array, multiple depths' => [ + 'flatten', + [ + 'array' => [ + 'a' => [ + 'a1' => [ + 'a1.1' => 1.1, + 'a1.2' => 1.2 + ], + 'a2' => 2 + ], + 'b' => 3, + ], + ], + [ + 'a1.1' => 1.1, + 'a1.2' => 1.2, + 'a2' => 2, + 'b' => 3 + ] + ], + 'flatten array, and resets array keys' => [ + 'flatten', + [ + 'array' => [ + 'a' => [ + 'a1' => 1.1, + 'a2' => 1.2 + ], + 'b' => 2, + ], + 'depth' => 1.0, + 'preserveKeys' => false, + ], + [ + 1.1, + 1.2, + 2 + ] + ], + 'flatten array, and resets array keys, multiple depths' => [ + 'flatten', + [ + 'array' => [ + 'a' => [ + 'a1' => [ + 'a1.1' => 1.1, + 'a1.2' => 1.2 + ], + 'a2' => 2 + ], + 'b' => 3, + ], + 'preserveKeys' => false, + ], + [ + 1.1, + 1.2, + 2, + 3 + ] + ], + 'flatten array, one depth, not enough' => [ + 'flatten', + [ + 'array' => [ + 'a' => [ + 'a1' => [ + 'a1.1' => 1.1, + 'a1.2' => 1.2 + ], + 'a2' => 2 + ], + 'b' => 3, + ], + 'depth' => 1, + 'preserveKeys' => false, + ], + [ + [ + 'a1.1' => 1.1, + 'a1.2' => 1.2 + ], + 2, + 3 + ] + ] + ]; + } } diff --git a/tests/Unit/Support/CollectionTest.php b/tests/Unit/Support/CollectionTest.php new file mode 100644 index 0000000..9039364 --- /dev/null +++ b/tests/Unit/Support/CollectionTest.php @@ -0,0 +1,238 @@ + $collection + * @param array $functionParams + */ + #[Test] + #[DataProvider('push_function_provider')] + #[DataProvider('put_function_provider')] + #[DataProvider('first_function_provider')] + #[DataProvider('map_function_provider')] + #[DataProvider('collapse_function_provider')] + #[DataProvider('flatten_function_provider')] + #[DataProvider('all_function_provider')] + #[DataProvider('validation_functions_provider')] + public function will_be_able_to_perform_functions( + Collection $collection, + string $functionName, + array $functionParams, + mixed $expected + ): void { + $result = $collection->{$functionName}(...$functionParams); + + if ($expected instanceof Collection) { + self::assertInstanceOf(Collection::class, $result); + self::assertCollection($result, $expected); + } else { + self::assertEquals($expected, $result); + } + } + + /** + * @return array + */ + public static function push_function_provider(): array + { + return [ + 'push' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'push', + 'functionParams' => [ 'item' => 4 ], + 'expected' => new Collection([1, 2, 3, 4]) + ] + ]; + } + + /** + * @return array + */ + public static function put_function_provider(): array + { + return [ + 'put in new key' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'put', + 'functionParams' => [ 'key' => 3, 'value' => 4 ], + 'expected' => new Collection([1, 2, 3, 4]) + ], + 'put in existing key will override value' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'put', + 'functionParams' => [ 'key' => 0, 'value' => 4 ], + 'expected' => new Collection([4, 2, 3]) + ] + ]; + } + + /** + * @return array + */ + public static function first_function_provider(): array + { + return [ + 'first without callable and non empty collection' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'first', + 'functionParams' => [], + 'expected' => 1 + ], + 'first without callable and empty collection' => [ + 'collection' => new Collection([]), + 'functionName' => 'first', + 'functionParams' => [], + 'expected' => null + ], + 'first with callable and existing item that will meet requirements' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'first', + 'functionParams' => [ 'callable' => static fn (int $item) => $item === 2 ], + 'expected' => 2 + ], + 'first with callable and no item that will meet requirements' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'first', + 'functionParams' => [ 'callable' => static fn (int $item) => $item === 4 ], + 'expected' => null + ] + ]; + } + + /** + * @return array + */ + public static function map_function_provider(): array + { + return [ + 'map' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'map', + 'functionParams' => [ 'callable' => static fn (int $item) => $item * 2 ], + 'expected' => new Collection([2, 4, 6]) + ] + ]; + } + + /** + * @return array + */ + public static function collapse_function_provider(): array + { + return [ + 'collapse' => [ + 'collection' => new Collection([ + new Collection([ 'a' => 1, 2, 3]), + new Collection([4, 5, 6]), + new Collection([7, 8, 9]) + ]), + 'functionName' => 'collapse', + 'functionParams' => [], + 'expected' => new Collection([1, 2, 3, 4, 5, 6, 7, 8, 9]) + ], + + ]; + } + + /** + * @return array + */ + public static function flatten_function_provider(): array + { + return [ + 'flatten' => [ + 'collection' => new Collection([ + 'a1' => new Collection([ + 'a1.1' => 1.1, + 'a1.2' => new Collection([ + 'a1.2.1' => 1.21, + 'a1.2.2' => 1.22, + 'a1.2.3' => new Collection([ + 'a1.2.3.1' => 1.231, + 'a1.2.3.2' => 1.232, + 'a1.2.3.3' => 1.233 + ]) + ]) + ]) + ]), + 'functionName' => 'flatten', + 'functionParams' => [], + 'expected' => new Collection([ + 'a1.1' => 1.1, + 'a1.2.1' => 1.21, + 'a1.2.2' => 1.22, + 'a1.2.3.1' => 1.231, + 'a1.2.3.2' => 1.232, + 'a1.2.3.3' => 1.233 + ]) + ] + ]; + } + + /** + * @return array + */ + public static function all_function_provider(): array + { + return [ + 'all' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'all', + 'functionParams' => [], + 'expected' => [1, 2, 3] + ] + ]; + } + + /** + * @return array + */ + public static function validation_functions_provider(): array + { + return [ + 'isEmpty' => [ + 'collection' => new Collection([]), + 'functionName' => 'isEmpty', + 'functionParams' => [], + 'expected' => true + ], + 'isNotEmpty' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'isNotEmpty', + 'functionParams' => [], + 'expected' => true + ], + 'every' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'every', + 'functionParams' => [ 'callable' => static fn (int $item) => $item > 0 ], + 'expected' => true + ], + 'some' => [ + 'collection' => new Collection([1, 2, 3]), + 'functionName' => 'some', + 'functionParams' => [ 'callable' => static fn (int $item) => $item === 2 ], + 'expected' => true + ], + ]; + } + + /** + * @param Collection $collection + * @param Collection $expected + */ + private static function assertCollection(Collection $collection, Collection $expected): void + { + self::assertEquals($expected->all(), $collection->all()); + } +} diff --git a/tests/Unit/Validation/Logical/AndRuleTest.php b/tests/Unit/Validation/Logical/AndRuleTest.php new file mode 100644 index 0000000..ca703df --- /dev/null +++ b/tests/Unit/Validation/Logical/AndRuleTest.php @@ -0,0 +1,70 @@ +, + * ruleEvaluators: list, + * expectedCreateException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool, + * expectedValidationMessageTree: array + * }> + * + * @throws Throwable + */ + public static function data_provider(): array + { + return [ + 'Will be able to use validation rules' => [ + 'logicalRuleClassString' => AndRule::class, + 'ruleEvaluators' => [ + $stringRule = StringRule::make(), + $emailRule = EmailRule::make() + ], + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 'string', + 'expectedResult' => false, + 'expectedValidationMessageTree' => [ + 'and' => [ + $stringRule->name => $stringRule->validationMessage(), + $emailRule->name => $emailRule->validationMessage() + ] + ] + ], + 'Will be able to use validation rules 2' => [ + 'logicalRuleClassString' => AndRule::class, + 'ruleEvaluators' => [ + $stringRule = StringRule::make(), + $emailRule = EmailRule::make() + ], + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 'johndoe@example.test', + 'expectedResult' => true, + 'expectedValidationMessageTree' => [ + 'and' => [ + $stringRule->name => $stringRule->validationMessage(), + $emailRule->name => $emailRule->validationMessage() + ] + ] + ] + ]; + } +} diff --git a/tests/Unit/Validation/Logical/LogicalRuleTestCase.php b/tests/Unit/Validation/Logical/LogicalRuleTestCase.php new file mode 100644 index 0000000..0c04c17 --- /dev/null +++ b/tests/Unit/Validation/Logical/LogicalRuleTestCase.php @@ -0,0 +1,69 @@ + $logicalRuleClassString + * @param list $ruleEvaluators + * @param ?class-string $expectedCreateException + * @param array $expectedValidationMessageTree + * + * @throws Throwable + */ + #[Test] + #[DataProvider('data_provider')] + public function will_be_able_to_use_logical_rules( + string $logicalRuleClassString, + array $ruleEvaluators, + ?string $expectedCreateException, + mixed $valueToBeEvaluated, + bool $expectedResult, + array $expectedValidationMessageTree + ): void { + if ($expectedCreateException) { + self::expectException($expectedCreateException); + } + + $logicalRule = new $logicalRuleClassString(); + + foreach ($ruleEvaluators as $ruleEvaluator) { + $logicalRule->addRule($ruleEvaluator); + } + + if ($expectedCreateException) { + return; + } + + self::assertEquals( + $expectedResult, + $logicalRule->evaluate($valueToBeEvaluated) + ); + + self::assertEquals( + $expectedValidationMessageTree, + $logicalRule->validationMessageTree() + ); + } + + /** + * @return array, + * ruleEvaluators: list, + * expectedCreateException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool, + * expectedValidationMessageTree: array + * }> + */ + abstract public static function data_provider(): array; +} diff --git a/tests/Unit/Validation/Logical/OrRuleTest.php b/tests/Unit/Validation/Logical/OrRuleTest.php new file mode 100644 index 0000000..c5c3e78 --- /dev/null +++ b/tests/Unit/Validation/Logical/OrRuleTest.php @@ -0,0 +1,83 @@ +, + * ruleEvaluators: list, + * expectedCreateException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool, + * expectedValidationMessageTree: array + * }> + * + * @throws Throwable + */ + public static function data_provider(): array + { + return [ + 'Will be able to use validation rules' => [ + 'logicalRuleClassString' => OrRule::class, + 'ruleEvaluators' => [ + $stringRule = StringRule::make(), + $numericRule = NumericRule::make() + ], + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 'string', + 'expectedResult' => true, + 'expectedValidationMessageTree' => [ + 'or' => [ + $stringRule->name => $stringRule->validationMessage(), + $numericRule->name => $numericRule->validationMessage() + ] + ] + ], + 'Will be able to use validation rules 2' => [ + 'logicalRuleClassString' => OrRule::class, + 'ruleEvaluators' => [ + $stringRule = StringRule::make(), + $numericRule = NumericRule::make() + ], + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 1234, + 'expectedResult' => true, + 'expectedValidationMessageTree' => [ + 'or' => [ + $stringRule->name => $stringRule->validationMessage(), + $numericRule->name => $numericRule->validationMessage() + ] + ] + ], + 'Will be able to use validation rule 3' => [ + 'logicalRuleClassString' => OrRule::class, + 'ruleEvaluators' => [ + $numericRule = NumericRule::make() + ], + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 1234.45, + 'expectedResult' => false, + 'expectedValidationMessageTree' => [ + 'or' => [ + $numericRule->name => $numericRule->validationMessage() + ] + ] + ] + ]; + } +} diff --git a/tests/Unit/Validation/Logical/SingularRuleTest.php b/tests/Unit/Validation/Logical/SingularRuleTest.php new file mode 100644 index 0000000..44fabe6 --- /dev/null +++ b/tests/Unit/Validation/Logical/SingularRuleTest.php @@ -0,0 +1,118 @@ +, + * ruleEvaluators: list, + * expectedCreateException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool, + * expectedValidationMessageTree: array + * }> + * + * @throws Throwable + */ + public static function data_provider(): array + { + /** @var list $ruleEvaluators */ + $ruleEvaluators = [ + $orRule = new OrRule() + ->addRule( + $andRule = new AndRule() + ->addRule($requiredRule = RequiredRule::make()) + ->addRule($emailRule = EmailRule::make()) + ) + ->addRule( + $nullableRule = NullableRule::make() + ) + ]; + + /** @var array $validationTree */ + $validationTree = [ + 'singular' => [ + 'or' => [ + 'and' => [ + 'required' => $requiredRule->validationMessage(), + 'email' => $emailRule->validationMessage() + ], + 'nullable' => $nullableRule->validationMessage() + ] + ] + ]; + + return [ + 'Will throw an exception when trying to add more than one rule' => [ + 'logicalRuleClassString' => SingularRule::class, + 'ruleEvaluators' => [ + RequiredRule::make(), + RequiredRule::make() + ], + 'expectedCreateException' => LogicalRuleException::class, + 'valueToBeEvaluated' => 'value', + 'expectedResult' => false, + 'expectedValidationMessageTree' => [] + ], + 'Will be able to use validation rules' => [ + 'logicalRuleClassString' => SingularRule::class, + 'ruleEvaluators' => [ + $ruleA = RequiredRule::make() + ], + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 'value', + 'expectedResult' => true, + 'expectedValidationMessageTree' => [ + 'singular' => [ + $ruleA->name => $ruleA->validationMessage() + ] + ] + ], + 'Will be able to resolve complex validations using OrRule and AndRule' => [ + 'logicalRuleClassString' => SingularRule::class, + 'ruleEvaluators' => $ruleEvaluators, + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 'johndoe@example.test', + 'expectedResult' => true, + 'expectedValidationMessageTree' => $validationTree + ], + 'Will be able to resolve complex validations using OrRule and AndRule 2' => [ + 'logicalRuleClassString' => SingularRule::class, + 'ruleEvaluators' => $ruleEvaluators, + 'expectedCreateException' => null, + 'valueToBeEvaluated' => null, + 'expectedResult' => true, + 'expectedValidationMessageTree' => $validationTree, + ], + 'Will be able to resolve complex validations using OrRule and AndRule 3' => [ + 'logicalRuleClassString' => SingularRule::class, + 'ruleEvaluators' => $ruleEvaluators, + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 1234.5, + 'expectedResult' => false, + 'expectedValidationMessageTree' => $validationTree, + ] + ]; + } +} diff --git a/tests/Unit/Validation/Logical/TruthyRuleTest.php b/tests/Unit/Validation/Logical/TruthyRuleTest.php new file mode 100644 index 0000000..7db382d --- /dev/null +++ b/tests/Unit/Validation/Logical/TruthyRuleTest.php @@ -0,0 +1,54 @@ +, + * ruleEvaluators: list, + * expectedCreateException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool, + * expectedValidationMessageTree: array + * }> + * + * @throws Throwable + */ + public static function data_provider(): array + { + return [ + 'Will always return true when validating' => [ + 'logicalRuleClassString' => TruthyRule::class, + 'ruleEvaluators' => [], + 'expectedCreateException' => null, + 'valueToBeEvaluated' => 'string', + 'expectedResult' => true, + 'expectedValidationMessageTree' => [] + ], + 'Will throw an exception if trying to add a nested rule' => [ + 'logicalRuleClassString' => TruthyRule::class, + 'ruleEvaluators' => [ + StringRule::make(), + ], + 'expectedCreateException' => LogicalRuleException::class, + 'valueToBeEvaluated' => 1234, + 'expectedResult' => true, + 'expectedValidationMessageTree' => [] + ] + ]; + } +} diff --git a/tests/Unit/Validation/Rules/BackedEnumRuleTest.php b/tests/Unit/Validation/Rules/BackedEnumRuleTest.php new file mode 100644 index 0000000..ee313b7 --- /dev/null +++ b/tests/Unit/Validation/Rules/BackedEnumRuleTest.php @@ -0,0 +1,137 @@ + YesNoBackedEnum::class + ]); + + self::assertEquals( + 'The :attribute field is invalid.', + $rule->validationMessage() + ); + + $rule = BackedEnumRule::make([ + 'backedEnumClass' => YesNoBackedEnum::class, + 'allowedValues' => $allowedValues = [YesNoBackedEnum::YES] + ]); + + $allowedValues = implode( + ', ', + array_map(static fn (YesNoBackedEnum $value) => $value->value, $allowedValues) + ); + + self::assertEquals( + "The :attribute field must be one of the following values: $allowedValues.", + $rule->validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will_throw_an_exception_if_no_enum_class_is_passed' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => '', + 'expectedResult' => false, + ], + 'Will_throw_an_exception_if_invalid_backed_enum_class_is_passed' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [ + 'backedEnumClass' => 'InvalidEnumClass' + ], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => '', + 'expectedResult' => false, + ], + 'Will_throw_an_exception_if_invalid_non_backed_enum_class_is_passed' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [ + 'backedEnumClass' => YesNoEnum::class + ], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => '', + 'expectedResult' => false, + ], + 'Will return false if the value is invalid backed enum value' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [ + 'backedEnumClass' => YesNoBackedEnum::class + ], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'Something', + 'expectedResult' => false, + ], + 'Will return false if the value is neither a backed enum, nor a string or an integer' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [ + 'backedEnumClass' => YesNoBackedEnum::class + ], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => [], + 'expectedResult' => false, + ], + 'Will return true if the value is a valid backed enum value' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [ + 'backedEnumClass' => YesNoBackedEnum::class + ], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'yes', + 'expectedResult' => true, + ], + 'Will return false if the value is not within the allowed values' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [ + 'backedEnumClass' => YesNoBackedEnum::class, + 'allowedValues' => [YesNoBackedEnum::NO] + ], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'yes', + 'expectedResult' => false, + ], + 'Will return true if the value passed if an actual backed enum of the same instance' => [ + 'validationRuleClassString' => BackedEnumRule::class, + 'makeParams' => [ + 'backedEnumClass' => YesNoBackedEnum::class + ], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => YesNoBackedEnum::YES, + 'expectedResult' => true, + ] + ]; + } +} diff --git a/tests/Unit/Validation/Rules/DateRuleTest.php b/tests/Unit/Validation/Rules/DateRuleTest.php new file mode 100644 index 0000000..e49f103 --- /dev/null +++ b/tests/Unit/Validation/Rules/DateRuleTest.php @@ -0,0 +1,96 @@ +validationMessage() + ); + + self::assertEquals( + 'The :attribute field must be a valid date in one of the following formats: Y/m-d H/m/s', + DateRule::make(['formats' => ['Y/m-d H/m/s']])->validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will evaluate false when value is not a valid datetime string' => [ + 'validationRuleClassString' => DateRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'not-a-valid-datetime-string', + 'expectedResult' => false + ], + 'Will evaluate true when a valid datetime string is provided (Y-m-d)' => [ + 'validationRuleClassString' => DateRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => '2021-01-01', + 'expectedResult' => true + ], + 'Will evaluate true when a valid datetime string is provided (ATOM)' => [ + 'validationRuleClassString' => DateRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => '2021-01-01T00:00:00+00:00', + 'expectedResult' => true + ], + 'Will evaluate true when a valid datetime string is provided (Y-m-d H:m:s)' => [ + 'validationRuleClassString' => DateRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => '2021-01-01 00:00:00', + 'expectedResult' => true + ], + 'Will evaluate false when a custom datetime string is provided but no formats are set' => [ + 'validationRuleClassString' => DateRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => '2021/01-01 00/00/00', + 'expectedResult' => false + ], + 'Will evaluate true when a custom datetime string is provided and a format is set' => [ + 'validationRuleClassString' => DateRule::class, + 'makeParams' => ['formats' => ['Y/m-d H/m/s']], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => '2021/01-01 00/00/00', + 'expectedResult' => true + ], + 'Will throw an exception when an invalid non string format is passed' => [ + 'validationRuleClassString' => DateRule::class, + 'makeParams' => ['formats' => ['Y/m-d H/m/s', 123]], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => '2021/01-01 00/00/00', + 'expectedResult' => false + ], + ]; + } +} diff --git a/tests/Unit/Validation/Rules/EmailRuleTest.php b/tests/Unit/Validation/Rules/EmailRuleTest.php new file mode 100644 index 0000000..839b926 --- /dev/null +++ b/tests/Unit/Validation/Rules/EmailRuleTest.php @@ -0,0 +1,62 @@ +validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will evaluate false when value is not a string' => [ + 'validationRuleClassString' => EmailRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test1234', + 'expectedResult' => false + ], + 'Will evaluate false when a string value is provided but it is not a valid email address' => [ + 'validationRuleClassString' => EmailRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + 'Will evaluate true when a valid email address is provided' => [ + 'validationRuleClassString' => EmailRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'johndoe@example.com', + 'expectedResult' => true, + ], + ]; + } +} diff --git a/tests/Unit/Validation/Rules/NullableRuleTest.php b/tests/Unit/Validation/Rules/NullableRuleTest.php new file mode 100644 index 0000000..106df6f --- /dev/null +++ b/tests/Unit/Validation/Rules/NullableRuleTest.php @@ -0,0 +1,55 @@ +validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will return true if the value is null' => [ + 'validationRuleClassString' => NullableRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => null, + 'expectedResult' => true, + ], + 'Will return false if the value is different then null' => [ + 'validationRuleClassString' => NullableRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'Something', + 'expectedResult' => false, + ] + ]; + } +} diff --git a/tests/Unit/Validation/Rules/NumericRuleTest.php b/tests/Unit/Validation/Rules/NumericRuleTest.php new file mode 100644 index 0000000..8a34c92 --- /dev/null +++ b/tests/Unit/Validation/Rules/NumericRuleTest.php @@ -0,0 +1,105 @@ +validationMessage() + ); + + self::assertEquals( + 'The :attribute field must be a valid float.', + NumericRule::make(['type' => Type::FLOAT])->validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will evaluate false when value is not a number' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + 'Will evaluate true when an integer value is provided' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => ['type' => Type::INT], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 123, + 'expectedResult' => true + ], + 'Will evaluate false when an integer value is provided but min is greater than the value' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => ['type' => Type::INT, 'min' => 124], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 123, + 'expectedResult' => false + ], + 'Will evaluate false when an integer value is provided but max is less than the value' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => ['type' => Type::INT, 'max' => 122], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 123, + 'expectedResult' => false + ], + 'Will evaluate true when a float value is provided' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => ['type' => Type::FLOAT], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 123.45, + 'expectedResult' => true + ], + 'Will evaluate false when a float value is provided but min is greater than the value' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => ['type' => Type::FLOAT, 'min' => 123.46], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 123.45, + 'expectedResult' => false + ], + 'Will evaluate false when a float value is provided but max is less than the value' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => ['type' => Type::FLOAT, 'max' => 123.44], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 123.45, + 'expectedResult' => false + ], + 'Will throw an exception if an invalid type is provided' => [ + 'validationRuleClassString' => NumericRule::class, + 'makeParams' => ['type' => Type::BOOLEAN], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => 123, + 'expectedResult' => false + ], + ]; + } +} diff --git a/tests/Unit/Validation/Rules/RegexRuleTest.php b/tests/Unit/Validation/Rules/RegexRuleTest.php new file mode 100644 index 0000000..26b9a59 --- /dev/null +++ b/tests/Unit/Validation/Rules/RegexRuleTest.php @@ -0,0 +1,72 @@ + '/^test$/']); + + self::assertEquals( + 'The :attribute field does not match the required pattern.', + $rule->validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will evaluate false when value is not a valid regex string' => [ + 'validationRuleClassString' => RegexRule::class, + 'makeParams' => ['pattern' => '/^test$/'], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'not-a-valid-regex-string', + 'expectedResult' => false + ], + 'Will evaluate true when a valid regex string is provided' => [ + 'validationRuleClassString' => RegexRule::class, + 'makeParams' => ['pattern' => '/^test$/'], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => true + ], + 'Will evaluate false when a non string value is provided' => [ + 'validationRuleClassString' => RegexRule::class, + 'makeParams' => ['pattern' => '/^test$/'], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 123, + 'expectedResult' => false + ], + 'Will throw an exception if the pattern is not a valid regex' => [ + 'validationRuleClassString' => RegexRule::class, + 'makeParams' => ['pattern' => '/^test'], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + ]; + } +} diff --git a/tests/Unit/Validation/Rules/RequiredRuleTest.php b/tests/Unit/Validation/Rules/RequiredRuleTest.php new file mode 100644 index 0000000..eaee83c --- /dev/null +++ b/tests/Unit/Validation/Rules/RequiredRuleTest.php @@ -0,0 +1,69 @@ +validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will return false if the value is empty string' => [ + 'validationRuleClassString' => RequiredRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => '', + 'expectedResult' => false, + ], + 'Will return false if the value is null' => [ + 'validationRuleClassString' => RequiredRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => null, + 'expectedResult' => false, + ], + 'Will return false if the value is falsy' => [ + 'validationRuleClassString' => RequiredRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => false, + 'expectedResult' => false, + ], + 'Will return true otherwise' => [ + 'validationRuleClassString' => RequiredRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'Something', + 'expectedResult' => true, + ] + ]; + } +} diff --git a/tests/Unit/Validation/Rules/StringRuleTest.php b/tests/Unit/Validation/Rules/StringRuleTest.php new file mode 100644 index 0000000..02f3b3a --- /dev/null +++ b/tests/Unit/Validation/Rules/StringRuleTest.php @@ -0,0 +1,125 @@ +validationMessage() + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + public static function data_provider(): array + { + return [ + 'Will evaluate false when value is not a string' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => null, + 'expectedResult' => false + ], + 'Will evaluate true when a string value is provided' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => null, + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => true + ], + 'Will evaluate false when a string value is provided but minLen is greater than the length of the string' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['minLen' => 5], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + 'Will evaluate true when a string value is provided and minLen is less than the length of the string' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['minLen' => 3], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => true + ], + 'Will evaluate false when a string value is provided but maxLen is less than the length of the string' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['maxLen' => 3], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + 'Will evaluate true when a string value is provided and maxLen is greater than the length of the string' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['maxLen' => 5], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => true + ], + 'Will evaluate false when a string value is provided but minLen is greater than the length of the string and maxLen is less than the length of the string' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['minLen' => 5, 'maxLen' => 10], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + 'Will evaluate true when a string value is provided and minLen is less than the length of the string and maxLen is greater than the length of the string' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['minLen' => 3, 'maxLen' => 5], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => true + ], + 'Will throw an exception if minLen is less than 0' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['minLen' => -1], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + 'Will throw an exception if maxLen is less than minLen' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['minLen' => 5, 'maxLen' => 3], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + 'Will evaluate alpha numeric strings as true when a param is passed' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['type' => 'alpha'], + 'expectedMakeException' => null, + 'valueToBeEvaluated' => 'test123', + 'expectedResult' => true + ], + 'Will throw a validation exception when an invalid type is passed' => [ + 'validationRuleClassString' => StringRule::class, + 'makeParams' => ['type' => 'invalid'], + 'expectedMakeException' => ValidationRuleException::class, + 'valueToBeEvaluated' => 'test', + 'expectedResult' => false + ], + ]; + } +} diff --git a/tests/Unit/Validation/Rules/ValidationRuleTestCase.php b/tests/Unit/Validation/Rules/ValidationRuleTestCase.php new file mode 100644 index 0000000..c207995 --- /dev/null +++ b/tests/Unit/Validation/Rules/ValidationRuleTestCase.php @@ -0,0 +1,59 @@ + $validationRuleClassString + * @param ?array $makeParams + * @param ?class-string $expectedMakeException + * @param mixed $valueToBeEvaluated + * @param bool $expectedResult + * + * @throws Throwable + */ + #[Test] + #[DataProvider('data_provider')] + public function will_be_able_to_use_rule( + string $validationRuleClassString, + ?array $makeParams, + ?string $expectedMakeException, + mixed $valueToBeEvaluated, + bool $expectedResult + ): void { + if ($expectedMakeException) { + self::expectException($expectedMakeException); + $validationRuleClassString::make($makeParams); + + return; + } + + $rule = $validationRuleClassString::make($makeParams); + + self::assertEquals( + $expectedResult, + $rule->evaluate($valueToBeEvaluated) + ); + } + + /** + * @return array, + * makeParams: ?array, + * expectedMakeException: ?class-string, + * valueToBeEvaluated: mixed, + * expectedResult: bool + * }> + */ + abstract public static function data_provider(): array; +}