diff --git a/src/contracts/src/Validation/ParameterizedRule.php b/src/contracts/src/Validation/ParameterizedRule.php new file mode 100644 index 0000000000..d174149ba4 --- /dev/null +++ b/src/contracts/src/Validation/ParameterizedRule.php @@ -0,0 +1,15 @@ +field = $this->parseFieldReference($field); + $this->values = $values; } /** @@ -40,6 +44,7 @@ public function parameters(): array return [ $this->field, $this->value, + $this->values, ]; } diff --git a/src/data/src/Attributes/Validation/Confirmed.php b/src/data/src/Attributes/Validation/Confirmed.php index 81c5cad39a..f7fba1acd3 100644 --- a/src/data/src/Attributes/Validation/Confirmed.php +++ b/src/data/src/Attributes/Validation/Confirmed.php @@ -5,10 +5,21 @@ namespace Hypervel\Data\Attributes\Validation; use Attribute; +use Hypervel\Data\Support\Validation\References\FieldReference; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class Confirmed extends StringValidationAttribute { + protected ?FieldReference $field; + + /** + * Create a confirmed rule attribute. + */ + public function __construct(string|FieldReference|null $field = null) + { + $this->field = $field === null ? null : $this->parseFieldReference($field); + } + /** * Get the Validator rule keyword. */ @@ -22,6 +33,6 @@ public static function keyword(): string */ public function parameters(): array { - return []; + return $this->field === null ? [] : [$this->field]; } } diff --git a/src/data/src/Attributes/Validation/DateEquals.php b/src/data/src/Attributes/Validation/DateEquals.php index 43c126e474..9fd09b2598 100644 --- a/src/data/src/Attributes/Validation/DateEquals.php +++ b/src/data/src/Attributes/Validation/DateEquals.php @@ -7,6 +7,7 @@ use Attribute; use DateTimeInterface; use Hypervel\Data\Support\Validation\References\ExternalReference; +use Hypervel\Data\Support\Validation\References\FieldReference; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class DateEquals extends StringValidationAttribute @@ -14,7 +15,7 @@ class DateEquals extends StringValidationAttribute /** * Create a date-equals rule attribute. */ - public function __construct(protected string|DateTimeInterface|ExternalReference $date) + public function __construct(protected string|DateTimeInterface|FieldReference|ExternalReference $date) { } diff --git a/src/data/src/Attributes/Validation/DeclinedIf.php b/src/data/src/Attributes/Validation/DeclinedIf.php index 7ddd7d89ed..541c1bd4ac 100644 --- a/src/data/src/Attributes/Validation/DeclinedIf.php +++ b/src/data/src/Attributes/Validation/DeclinedIf.php @@ -14,14 +14,18 @@ class DeclinedIf extends StringValidationAttribute { protected FieldReference $field; + protected array $values; + /** * Create a declined-if rule attribute. */ public function __construct( string|FieldReference $field, protected string|bool|int|float|BackedEnum|ExternalReference $value, + string|bool|int|float|BackedEnum|ExternalReference ...$values, ) { $this->field = $this->parseFieldReference($field); + $this->values = $values; } /** @@ -40,6 +44,7 @@ public function parameters(): array return [ $this->field, $this->value, + $this->values, ]; } diff --git a/src/data/src/Attributes/Validation/ExcludeIf.php b/src/data/src/Attributes/Validation/ExcludeIf.php index ea2e696117..dd50e75ade 100644 --- a/src/data/src/Attributes/Validation/ExcludeIf.php +++ b/src/data/src/Attributes/Validation/ExcludeIf.php @@ -14,14 +14,18 @@ class ExcludeIf extends StringValidationAttribute { protected FieldReference $field; + protected array $values; + /** * Create a new exclude-if validation attribute. */ public function __construct( string|FieldReference $field, protected string|bool|int|float|BackedEnum|ExternalReference $value, + string|bool|int|float|BackedEnum|ExternalReference ...$values, ) { $this->field = $this->parseFieldReference($field); + $this->values = $values; } /** @@ -43,6 +47,7 @@ public function parameters(): array return [ $this->field, $this->value, + $this->values, ]; } } diff --git a/src/data/src/Attributes/Validation/ExcludeUnless.php b/src/data/src/Attributes/Validation/ExcludeUnless.php index c8316a4f7b..6d9f422d37 100644 --- a/src/data/src/Attributes/Validation/ExcludeUnless.php +++ b/src/data/src/Attributes/Validation/ExcludeUnless.php @@ -14,14 +14,18 @@ class ExcludeUnless extends StringValidationAttribute { protected FieldReference $field; + protected array $values; + /** * Create a new exclude-unless validation attribute. */ public function __construct( string|FieldReference $field, protected string|bool|int|float|BackedEnum|ExternalReference $value, + string|bool|int|float|BackedEnum|ExternalReference ...$values, ) { $this->field = $this->parseFieldReference($field); + $this->values = $values; } /** @@ -40,6 +44,7 @@ public function parameters(): array return [ $this->field, $this->value, + $this->values, ]; } } diff --git a/src/data/src/Attributes/Validation/ExcludeWithout.php b/src/data/src/Attributes/Validation/ExcludeWithout.php index 5e7c38f71b..1ea66221f3 100644 --- a/src/data/src/Attributes/Validation/ExcludeWithout.php +++ b/src/data/src/Attributes/Validation/ExcludeWithout.php @@ -12,13 +12,18 @@ class ExcludeWithout extends StringValidationAttribute { protected FieldReference $field; + /** @var array */ + protected array $fields; + /** * Create a new exclude-without validation attribute. */ public function __construct( string|FieldReference $field, + string|FieldReference ...$fields, ) { $this->field = $this->parseFieldReference($field); + $this->fields = array_map($this->parseFieldReference(...), $fields); } /** @@ -36,6 +41,7 @@ public function parameters(): array { return [ $this->field, + $this->fields, ]; } } diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index 7e1eef687e..ef2dc8fa43 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -8,6 +8,7 @@ use Closure; use DateTimeInterface; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Data\Attributes\Validation\ArrayType; use Hypervel\Data\Attributes\Validation\BooleanType; use Hypervel\Data\Attributes\Validation\Enum; @@ -1591,9 +1592,7 @@ protected function hasDistinctRule(array $rules): bool return true; } - if (is_string($rule) - && strtolower(trim(explode(':', $rule, 2)[0])) === 'distinct' - ) { + if ($this->ruleKeyword($rule) === 'distinct') { return true; } } @@ -1633,13 +1632,7 @@ protected function hasPresenceRule(array $rules): bool return true; } - if (! is_string($rule)) { - continue; - } - - $name = strtolower(trim(explode(':', $rule, 2)[0])); - - if (in_array($name, self::PRESENCE_RULES, true)) { + if (in_array($this->ruleKeyword($rule), self::PRESENCE_RULES, true)) { return true; } } @@ -1647,6 +1640,18 @@ protected function hasPresenceRule(array $rules): bool return false; } + /** + * Get the keyword of a string or structured validation rule. + */ + protected function ruleKeyword(mixed $rule): ?string + { + if ($rule instanceof ParameterizedRule) { + $rule = $rule->toArray()[0]; + } + + return is_string($rule) ? strtolower(trim(explode(':', $rule, 2)[0])) : null; + } + /** * Determine if item rules can differ across one collection. * diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index 76f9a2a7cc..48bfe90b23 100644 --- a/src/data/src/Support/Validation/RuleDenormalizer.php +++ b/src/data/src/Support/Validation/RuleDenormalizer.php @@ -119,6 +119,11 @@ protected function normalizeRuleParameter( return null; } + if (is_float($parameter)) { + // String casts round finite floats, JSON cannot encode INF, and casting NAN warns on PHP 8.5. + return is_nan($parameter) ? 'NAN' : (json_encode($parameter) ?: (string) $parameter); + } + if (is_string($parameter) || is_numeric($parameter)) { return (string) $parameter; } diff --git a/src/data/src/Support/Validation/RuleNormalizer.php b/src/data/src/Support/Validation/RuleNormalizer.php index acf777e0e4..1cdd149cc1 100644 --- a/src/data/src/Support/Validation/RuleNormalizer.php +++ b/src/data/src/Support/Validation/RuleNormalizer.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Contracts\Validation\CompilableRules; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Contracts\Validation\ValidationRule as ValidationRuleContract; use Hypervel\Data\Attributes\Validation\Dimensions; @@ -64,6 +65,10 @@ public function execute( return $this->resolveStringRule($rule); } + if ($rule instanceof ParameterizedRule) { + return [$this->resolveRule($rule)]; + } + if ($rule instanceof Rule) { return $this->execute($rule->get()); } @@ -122,13 +127,21 @@ protected function resolveStringRule(string $rule): array ? [$rule] : explode('|', $rule); foreach ($subRules as $subRule) { - try { - $rules[] = $this->ruleFactory->create($subRule); - } catch (CouldNotCreateValidationRule|CannotBuildValidationRule|TypeError|ValueError) { - $rules[] = new Rule($subRule); - } + $rules[] = $this->resolveRule($subRule); } return $rules; } + + /** + * Convert a rule to its validation attribute, keeping it as written when no attribute can hold its parameters. + */ + protected function resolveRule(string|ParameterizedRule $rule): ValidationRule + { + try { + return $this->ruleFactory->create($rule); + } catch (CouldNotCreateValidationRule|CannotBuildValidationRule|TypeError|ValueError) { + return new Rule($rule); + } + } } diff --git a/src/data/src/Support/Validation/ValidationAccumulator.php b/src/data/src/Support/Validation/ValidationAccumulator.php index bfd5fe5025..11e5d41bf6 100644 --- a/src/data/src/Support/Validation/ValidationAccumulator.php +++ b/src/data/src/Support/Validation/ValidationAccumulator.php @@ -4,6 +4,7 @@ namespace Hypervel\Data\Support\Validation; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Validation\Rules\Enum; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Unique; @@ -147,6 +148,10 @@ protected function ruleValueEquals(mixed $rule, mixed $otherRule): bool && $this->rulesEqual($rule, $otherRule); } + if ($rule instanceof ParameterizedRule) { + return $otherRule instanceof ParameterizedRule && $rule->toArray() === $otherRule->toArray(); + } + // Each item compiles its own inferred enum rule, so equal rules compare by their type and case restrictions. if ($rule instanceof Enum) { return $otherRule instanceof Enum && $rule == $otherRule; diff --git a/src/data/src/Support/Validation/ValidationRuleFactory.php b/src/data/src/Support/Validation/ValidationRuleFactory.php index 6d3584ed70..7c6c40a5cf 100644 --- a/src/data/src/Support/Validation/ValidationRuleFactory.php +++ b/src/data/src/Support/Validation/ValidationRuleFactory.php @@ -4,6 +4,7 @@ namespace Hypervel\Data\Support\Validation; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Data\Attributes\Validation\Accepted; use Hypervel\Data\Attributes\Validation\AcceptedIf; use Hypervel\Data\Attributes\Validation\ActiveUrl; @@ -252,18 +253,18 @@ class ValidationRuleFactory ]; /** - * Create the validation attribute for a Validator rule string. + * Create the validation attribute for a Validator rule. * * @throws CouldNotCreateValidationRule when no attribute has the rule's keyword */ - public function create(string $rule): ValidationRule + public function create(string|ParameterizedRule $rule): ValidationRule { [$keyword, $parameters] = ValidationRuleParser::parse($rule); $ruleClass = $this->mapping()[Str::snake($keyword)] ?? null; if ($ruleClass === null) { - throw CouldNotCreateValidationRule::create($rule); + throw CouldNotCreateValidationRule::create(is_string($rule) ? $rule : $keyword); } return $ruleClass::create(...$parameters); diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 1fbb3e7768..6d3004144e 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -1156,7 +1156,7 @@ public static function rules(ValidationContext $context): array The `ValidationContext` provides the current object's input as `payload`, the complete input as `fullPayload`, and the object's input `path`. A class rule replaces inferred rules for that property. Add `#[MergeValidationRules]` to the class to merge them instead. Property keys use PHP property names; Hypervel translates them to the mapped input paths. -Validation attributes, including `#[Rule]`, may reference another property by its PHP name; Hypervel converts it to the input name. Rule strings returned from `rules()` or rule hooks are passed to the validator as written, so use input names there, or return an attribute such as `new RequiredWith('lastName')`. +Validation attributes, including `#[Rule]`, may reference another property by its PHP name; Hypervel converts it to the input name. Date rules treat a plain string as written, so use `new FieldReference('lastName')` to reference a property. Rule strings and rule builders returned from `rules()` or rule hooks are passed to the validator as written, so use input names there, or return an attribute such as `new RequiredWith('lastName')`. For references from a nested object to the root input, use `FieldReference`: diff --git a/src/docs/validation.md b/src/docs/validation.md index c2384ade14..bd11da5b27 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -26,6 +26,7 @@ - [Specifying Attributes in Language Files](#specifying-attribute-in-language-files) - [Specifying Values in Language Files](#specifying-values-in-language-files) - [Available Validation Rules](#available-validation-rules) + - [Rule Builders](#rule-builders) - [Conditionally Adding Rules](#conditionally-adding-rules) - [Validating Arrays](#validating-arrays) - [Validating Nested Array Input](#validating-nested-array-input) @@ -1552,6 +1553,38 @@ Below is a list of all available validation rules and their function: + +### Rule Builders + +In addition to strings, many validation rules may be defined using methods on the `Hypervel\Validation\Rule` class. These methods offer IDE autocompletion and may be combined with string rules: + +```php +use Hypervel\Validation\Rule; + +'name' => [Rule::required(), Rule::string()->max(255)], +'terms' => [Rule::accepted()], +'enabled' => [Rule::boolean()], +'website' => [Rule::nullable(), Rule::activeUrl()], +``` + +Method names use camel case, such as `Rule::hexColor()`, `Rule::macAddress()`, and `Rule::requiredArrayKeys(['name', 'email'])`. Rules that depend on other fields accept the field names or comparison values as arguments: + +```php +'terms' => [Rule::acceptedIf('account_type', ['personal', 'business'])], +'nickname' => [Rule::excludeWith('name')], +'email' => [Rule::requiredWithout(['phone', 'mobile'])], +'settings' => [Rule::presentIf('account_type', 'business')], +``` + +The `acceptedIf`, `declinedIf`, `missingIf`, `missingUnless`, `presentIf`, and `presentUnless` methods accept a comparison value or an array of comparison values. Methods such as `requiredWith`, `requiredWithAll`, `requiredWithout`, `requiredWithoutAll`, `missingWith`, `missingWithAll`, `presentWith`, `presentWithAll`, and `prohibits` accept an array of field names or multiple field name arguments. + +Regular expressions may also be defined using the `regex` and `notRegex` methods: + +```php +'username' => [Rule::required(), Rule::regex('/^[a-z]+$/i')], +'code' => [Rule::notRegex('/^test-/i')], +``` + #### accepted @@ -1815,6 +1848,12 @@ You may use the `strict` parameter to only consider the field valid if its value 'foo' => ['boolean:strict'] ``` +Alternatively, you may use the `Rule::boolean` method, passing `true` to enable strict validation: + +```php +'foo' => [Rule::boolean(true)], +``` + #### confirmed @@ -1822,6 +1861,12 @@ The field under validation must have a matching field of `{field}_confirmation`. You may also pass a custom confirmation field name. For example, `confirmed:repeat_username` will expect the field `repeat_username` to match the field under validation. +You may also use the `Rule::confirmed` method, specifying a custom confirmation field via the `customField` method: + +```php +'username' => [Rule::confirmed()->customField('repeat_username')], +``` + #### contains:_foo_,_bar_,... @@ -1869,6 +1914,12 @@ The field under validation must match the authenticated user's password. You may 'password' => ['current_password:api'] ``` +Alternatively, you may use the `Rule::currentPassword` method and specify the authentication guard via the `guard` method: + +```php +'password' => [Rule::currentPassword()->guard('api')], +``` + #### date @@ -1879,6 +1930,16 @@ The field under validation must be a valid, non-relative date according to the ` The field under validation must be equal to the given date. The dates will be passed into the PHP `strtotime` function in order to be converted into a valid `DateTime` instance. +For convenience, date-based rules may be constructed using the fluent `date` rule builder: + +```php +use Hypervel\Validation\Rule; + +'start_date' => [Rule::date()->equals('2026-01-01')], +``` + +The `equals` method accepts a date string, a `DateTimeInterface` instance, or the name of another field to compare against. + #### date_format:_format_,... @@ -2013,6 +2074,12 @@ You may add `ignore_case` to the validation rule's arguments to make the rule ig 'foo.*.id' => ['distinct:ignore_case'] ``` +Alternatively, you may use the `Rule::distinct` method, chaining the `strict` and `ignoreCase` methods as needed: + +```php +'foo.*.id' => [Rule::distinct()->strict()->ignoreCase()], +``` + #### doesnt_start_with:_foo_,_bar_,... @@ -2380,6 +2447,14 @@ You may use the `strict` parameter to only consider the field valid if its type The field under validation must be an IP address. +You may also use the `Rule::ip` method, optionally restricting the IP version to `4` or `6`: + +```php +'ip_address' => [Rule::ip()], +'ipv4_address' => [Rule::ip()->version(4)], +'ipv6_address' => [Rule::ip()->version(6)], +``` + #### ipv4 @@ -2834,6 +2909,12 @@ The arguments [accepted by the `DateTimeZone::listIdentifiers` method](https://w 'timezone' => ['required', 'timezone:per_country,US']; ``` +Alternatively, you may use the `Rule::timezone` method, passing any `listIdentifiers` arguments as an array: + +```php +'timezone' => [Rule::timezone(['per_country', 'US'])], +``` + #### unique:_table_,_column_ @@ -2943,6 +3024,12 @@ If you would like to specify the URL protocols that should be considered valid, 'game' => ['url:minecraft,steam'], ``` +Alternatively, you may use the `Rule::url` method, specifying the allowed protocols via the `protocols` method: + +```php +'url' => [Rule::url()->protocols(['http', 'https'])], +``` + #### ulid @@ -2959,6 +3046,12 @@ You may also validate that the given UUID matches a UUID specification by versio 'uuid' => ['uuid:4'] ``` +Alternatively, you may use the `Rule::uuid` method and specify the UUID version via the `version` method: + +```php +'uuid' => [Rule::uuid()->version(4)], +``` + ## Conditionally Adding Rules diff --git a/src/validation/src/DelegatedCheck.php b/src/validation/src/DelegatedCheck.php index a99270400a..1129af8d5f 100644 --- a/src/validation/src/DelegatedCheck.php +++ b/src/validation/src/DelegatedCheck.php @@ -18,7 +18,7 @@ * @param string $ruleName Parsed rule name (e.g., 'Exists', 'Required'). Empty for * Rule objects dispatched via validateUsingCustomRule(). * @param array $parameters parsed rule parameters - * @param mixed $originalRule The raw rule as it appears in the exploded rules array. + * @param mixed $originalRule The exploded rule, or its plain structured values in a cached base plan. * Set as $this->currentRule before dispatch so validateExists/ * validateUnique can check `$this->currentRule instanceof Exists`. */ diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php index ed242816cb..c34b7c8bdc 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -11,30 +11,63 @@ use Hypervel\Contracts\Validation\ValidationRule; use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; +use Hypervel\Validation\Rules\AcceptedIf; use Hypervel\Validation\Rules\AnyOf; use Hypervel\Validation\Rules\ArrayKeys; use Hypervel\Validation\Rules\ArrayRule; use Hypervel\Validation\Rules\Can; +use Hypervel\Validation\Rules\Confirmed; use Hypervel\Validation\Rules\Contains; +use Hypervel\Validation\Rules\CurrentPassword; use Hypervel\Validation\Rules\Date; +use Hypervel\Validation\Rules\DeclinedIf; use Hypervel\Validation\Rules\Dimensions; +use Hypervel\Validation\Rules\Distinct; use Hypervel\Validation\Rules\DoesntContain; use Hypervel\Validation\Rules\Email; use Hypervel\Validation\Rules\Enum; use Hypervel\Validation\Rules\ExcludeIf; use Hypervel\Validation\Rules\ExcludeUnless; +use Hypervel\Validation\Rules\ExcludeWith; +use Hypervel\Validation\Rules\ExcludeWithout; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\File; use Hypervel\Validation\Rules\ImageFile; use Hypervel\Validation\Rules\In; +use Hypervel\Validation\Rules\InArray; +use Hypervel\Validation\Rules\InArrayKeys; +use Hypervel\Validation\Rules\IpAddress; +use Hypervel\Validation\Rules\MissingIf; +use Hypervel\Validation\Rules\MissingUnless; +use Hypervel\Validation\Rules\MissingWith; +use Hypervel\Validation\Rules\MissingWithAll; use Hypervel\Validation\Rules\NotIn; +use Hypervel\Validation\Rules\NotRegex; use Hypervel\Validation\Rules\Numeric; +use Hypervel\Validation\Rules\PresentIf; +use Hypervel\Validation\Rules\PresentUnless; +use Hypervel\Validation\Rules\PresentWith; +use Hypervel\Validation\Rules\PresentWithAll; use Hypervel\Validation\Rules\ProhibitedIf; +use Hypervel\Validation\Rules\ProhibitedIfAccepted; +use Hypervel\Validation\Rules\ProhibitedIfDeclined; use Hypervel\Validation\Rules\ProhibitedUnless; +use Hypervel\Validation\Rules\Prohibits; +use Hypervel\Validation\Rules\Regex; +use Hypervel\Validation\Rules\RequiredArrayKeys; use Hypervel\Validation\Rules\RequiredIf; +use Hypervel\Validation\Rules\RequiredIfAccepted; +use Hypervel\Validation\Rules\RequiredIfDeclined; use Hypervel\Validation\Rules\RequiredUnless; +use Hypervel\Validation\Rules\RequiredWith; +use Hypervel\Validation\Rules\RequiredWithAll; +use Hypervel\Validation\Rules\RequiredWithout; +use Hypervel\Validation\Rules\RequiredWithoutAll; use Hypervel\Validation\Rules\StringRule; +use Hypervel\Validation\Rules\Timezone; use Hypervel\Validation\Rules\Unique; +use Hypervel\Validation\Rules\Url; +use Hypervel\Validation\Rules\Uuid; use InvalidArgumentException; use Stringable; use UnitEnum; @@ -138,19 +171,419 @@ public static function notIn(array|Arrayable|UnitEnum|string $values): NotIn } /** - * Get a contains rule builder instance. + * Get an accepted validation rule. */ - public static function contains(array|Arrayable|UnitEnum|string $values): Contains + public static function accepted(): string { - return new Contains(...func_get_args()); + return 'accepted'; } /** - * Get a doesnt_contain rule builder instance. + * Get an active_url validation rule. */ - public static function doesntContain(array|Arrayable|UnitEnum|string $values): DoesntContain + public static function activeUrl(): string { - return new DoesntContain(...func_get_args()); + return 'active_url'; + } + + /** + * Get a base64 validation rule. + */ + public static function base64(): string + { + return 'base64'; + } + + /** + * Get a bail validation rule. + */ + public static function bail(): string + { + return 'bail'; + } + + /** + * Get a boolean validation rule. + */ + public static function boolean(bool $strict = false): string + { + return $strict ? 'boolean:strict' : 'boolean'; + } + + /** + * Get a declined validation rule. + */ + public static function declined(): string + { + return 'declined'; + } + + /** + * Get an exclude validation rule. + */ + public static function exclude(): string + { + return 'exclude'; + } + + /** + * Get a filled validation rule. + */ + public static function filled(): string + { + return 'filled'; + } + + /** + * Get a hex_color validation rule. + */ + public static function hexColor(): string + { + return 'hex_color'; + } + + /** + * Get a json validation rule. + */ + public static function json(): string + { + return 'json'; + } + + /** + * Get a list validation rule. + */ + public static function list(): string + { + return 'list'; + } + + /** + * Get a mac_address validation rule. + */ + public static function macAddress(): string + { + return 'mac_address'; + } + + /** + * Get a missing validation rule. + */ + public static function missing(): string + { + return 'missing'; + } + + /** + * Get a nullable validation rule. + */ + public static function nullable(): string + { + return 'nullable'; + } + + /** + * Get a present validation rule. + */ + public static function present(): string + { + return 'present'; + } + + /** + * Get a prohibited validation rule. + */ + public static function prohibited(): string + { + return 'prohibited'; + } + + /** + * Get a required validation rule. + */ + public static function required(): string + { + return 'required'; + } + + /** + * Get a sometimes validation rule. + */ + public static function sometimes(): string + { + return 'sometimes'; + } + + /** + * Get an ulid validation rule. + */ + public static function ulid(): string + { + return 'ulid'; + } + + /** + * Get an accepted_if rule builder instance. + */ + public static function acceptedIf(string $anotherField, array|string|int|float|bool|null $value): AcceptedIf + { + return new AcceptedIf(...func_get_args()); + } + + /** + * Get a declined_if rule builder instance. + */ + public static function declinedIf(string $anotherField, array|string|int|float|bool|null $value): DeclinedIf + { + return new DeclinedIf(...func_get_args()); + } + + /** + * Get a missing_if rule builder instance. + */ + public static function missingIf(string $anotherField, array|string|int|float|bool|null $value): MissingIf + { + return new MissingIf(...func_get_args()); + } + + /** + * Get a missing_unless rule builder instance. + */ + public static function missingUnless(string $anotherField, array|string|int|float|bool|null $value): MissingUnless + { + return new MissingUnless(...func_get_args()); + } + + /** + * Get a present_if rule builder instance. + */ + public static function presentIf(string $anotherField, array|string|int|float|bool|null $value): PresentIf + { + return new PresentIf(...func_get_args()); + } + + /** + * Get a present_unless rule builder instance. + */ + public static function presentUnless(string $anotherField, array|string|int|float|bool|null $value): PresentUnless + { + return new PresentUnless(...func_get_args()); + } + + /** + * Get a confirmed rule builder instance. + */ + public static function confirmed(): Confirmed + { + return new Confirmed; + } + + /** + * Get a current_password rule builder instance. + */ + public static function currentPassword(): CurrentPassword + { + return new CurrentPassword; + } + + /** + * Get a distinct rule builder instance. + */ + public static function distinct(): Distinct + { + return new Distinct; + } + + /** + * Get an exclude_with rule builder instance. + */ + public static function excludeWith(string $anotherField): ExcludeWith + { + return new ExcludeWith($anotherField); + } + + /** + * Get an exclude_without rule builder instance. + */ + public static function excludeWithout(array|string $anotherField): ExcludeWithout + { + return new ExcludeWithout(...func_get_args()); + } + + /** + * Get an in_array rule builder instance. + */ + public static function inArray(string $otherField): InArray + { + return new InArray($otherField); + } + + /** + * Get an in_array_keys rule builder instance. + */ + public static function inArrayKeys(array|string $keys): InArrayKeys + { + return new InArrayKeys(...func_get_args()); + } + + /** + * Get an IP address rule builder instance. + */ + public static function ip(): IpAddress + { + return new IpAddress; + } + + /** + * Get a missing_with rule builder instance. + */ + public static function missingWith(array|string $fields): MissingWith + { + return new MissingWith(...func_get_args()); + } + + /** + * Get a missing_with_all rule builder instance. + */ + public static function missingWithAll(array|string $fields): MissingWithAll + { + return new MissingWithAll(...func_get_args()); + } + + /** + * Get a present_with rule builder instance. + */ + public static function presentWith(array|string $fields): PresentWith + { + return new PresentWith(...func_get_args()); + } + + /** + * Get a present_with_all rule builder instance. + */ + public static function presentWithAll(array|string $fields): PresentWithAll + { + return new PresentWithAll(...func_get_args()); + } + + /** + * Get a prohibited_if_accepted rule builder instance. + */ + public static function prohibitedIfAccepted(string $field): ProhibitedIfAccepted + { + return new ProhibitedIfAccepted($field); + } + + /** + * Get a prohibited_if_declined rule builder instance. + */ + public static function prohibitedIfDeclined(string $field): ProhibitedIfDeclined + { + return new ProhibitedIfDeclined($field); + } + + /** + * Get a prohibits rule builder instance. + */ + public static function prohibits(array|string $fields): Prohibits + { + return new Prohibits(...func_get_args()); + } + + /** + * Get a regex rule builder instance. + */ + public static function regex(string $pattern): Regex + { + return new Regex($pattern); + } + + /** + * Get a not_regex rule builder instance. + */ + public static function notRegex(string $pattern): NotRegex + { + return new NotRegex($pattern); + } + + /** + * Get a required_array_keys rule builder instance. + */ + public static function requiredArrayKeys(array|string $keys): RequiredArrayKeys + { + return new RequiredArrayKeys(...func_get_args()); + } + + /** + * Get a required_if_accepted rule builder instance. + */ + public static function requiredIfAccepted(string $field): RequiredIfAccepted + { + return new RequiredIfAccepted($field); + } + + /** + * Get a required_if_declined rule builder instance. + */ + public static function requiredIfDeclined(string $field): RequiredIfDeclined + { + return new RequiredIfDeclined($field); + } + + /** + * Get a required_with rule builder instance. + */ + public static function requiredWith(array|string $fields): RequiredWith + { + return new RequiredWith(...func_get_args()); + } + + /** + * Get a required_with_all rule builder instance. + */ + public static function requiredWithAll(array|string $fields): RequiredWithAll + { + return new RequiredWithAll(...func_get_args()); + } + + /** + * Get a required_without rule builder instance. + */ + public static function requiredWithout(array|string $fields): RequiredWithout + { + return new RequiredWithout(...func_get_args()); + } + + /** + * Get a required_without_all rule builder instance. + */ + public static function requiredWithoutAll(array|string $fields): RequiredWithoutAll + { + return new RequiredWithoutAll(...func_get_args()); + } + + /** + * Get a timezone rule builder instance. + */ + public static function timezone(?array $arguments = null): Timezone + { + return new Timezone($arguments); + } + + /** + * Get a URL rule builder instance. + */ + public static function url(): Url + { + return new Url; + } + + /** + * Get a UUID rule builder instance. + */ + public static function uuid(): Uuid + { + return new Uuid; } /** @@ -297,6 +730,22 @@ public static function anyOf(array $rules): AnyOf return new AnyOf($rules); } + /** + * Get a contains rule builder instance. + */ + public static function contains(array|Arrayable|UnitEnum|string $values): Contains + { + return new Contains(...func_get_args()); + } + + /** + * Get a doesnt_contain rule builder instance. + */ + public static function doesntContain(array|Arrayable|UnitEnum|string $values): DoesntContain + { + return new DoesntContain(...func_get_args()); + } + /** * Compile a set of rules for an attribute. */ diff --git a/src/validation/src/RuleCompiler.php b/src/validation/src/RuleCompiler.php index bd231a62c6..2f1d3795c3 100644 --- a/src/validation/src/RuleCompiler.php +++ b/src/validation/src/RuleCompiler.php @@ -4,6 +4,7 @@ namespace Hypervel\Validation; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Validation\Enums\CheckType; use Hypervel\Validation\Rules\Exists; @@ -159,11 +160,13 @@ private static function compileRule(mixed $rule, array $parsedRule, AttributePla return; } + // Cached base plans own plain values, never a caller's mutable builder. + // Subclass plans keep the original object for their validation hooks. self::appendDelegatedCheck( $plan, ruleName: $ruleName, parameters: $parameters, - originalRule: $rule, + originalRule: $rule instanceof ParameterizedRule ? [$ruleName, ...$parameters] : $rule, ); } diff --git a/src/validation/src/RulePlanCache.php b/src/validation/src/RulePlanCache.php index dcf0bb2f77..3d8b9e0bed 100644 --- a/src/validation/src/RulePlanCache.php +++ b/src/validation/src/RulePlanCache.php @@ -4,12 +4,13 @@ namespace Hypervel\Validation; +use Hypervel\Contracts\Validation\ParameterizedRule; use InvalidArgumentException; /** * Worker-lifetime LRU cache of compiled rule plans. * - * Plans are keyed by the normalized rule definition (string-only arrays). + * Plans are keyed by string rules or structured rules containing only scalar values. * Plans are pure data — safe to cache across requests. A cached null-equivalent * (un-cacheable) is represented by returning null from get(), not stored. * @@ -38,8 +39,7 @@ final class RulePlanCache /** * Get a cached plan for the given rule array. * - * Returns null if the rules contain non-string elements (un-cacheable) - * or if no cached plan exists. + * Return null for rules with executable or non-scalar state, or when no plan exists. * * @param list $rules */ @@ -106,10 +106,9 @@ public static function setMaxSize(int $size): void } /** - * Produce a stable cache key for string-only rule arrays. + * Produce a stable cache key for string rules and scalar structured rules. * - * Returns null if the array contains any non-string element (un-cacheable - * because Rule objects and Closures can't produce a stable key). + * Executable rules and non-scalar parameters remain uncached. * * @param list $rules */ @@ -118,7 +117,13 @@ private static function cacheKey(array $rules): ?string $parts = []; foreach ($rules as $item) { - if (! is_string($item)) { + if ($item instanceof ParameterizedRule) { + $item = $item->toArray(); + + if (! array_all($item, static fn (mixed $part): bool => is_scalar($part))) { + return null; + } + } elseif (! is_string($item)) { return null; } $parts[] = $item; diff --git a/src/validation/src/Rules/AcceptedIf.php b/src/validation/src/Rules/AcceptedIf.php new file mode 100644 index 0000000000..173f04697f --- /dev/null +++ b/src/validation/src/Rules/AcceptedIf.php @@ -0,0 +1,37 @@ + + */ + protected array $values; + + /** + * Create a new accepted_if rule instance. + */ + public function __construct(string $anotherField, string|int|float|bool|array|null $values) + { + $this->anotherField = $anotherField; + $this->values = is_array($values) ? $values : array_slice(func_get_args(), 1); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['accepted_if', $this->anotherField, ...$this->formatConditionalValues($this->values)]; + } +} diff --git a/src/validation/src/Rules/Concerns/FormatsParameters.php b/src/validation/src/Rules/Concerns/FormatsParameters.php new file mode 100644 index 0000000000..e758cfbaa4 --- /dev/null +++ b/src/validation/src/Rules/Concerns/FormatsParameters.php @@ -0,0 +1,50 @@ +toArray(); + $rule = array_shift($parameters); + + return $rule . ($parameters ? ':' . $this->formatParameters($parameters) : ''); + } + + /** + * Format conditional comparison values without rounding floats. + * + * @return list + */ + protected function formatConditionalValues(array $values): array + { + return array_map(static fn (string|int|float|bool|null $value): string => match (true) { + is_null($value) => 'null', + $value === true => 'true', + $value === false => 'false', + // JSON preserves finite precision but cannot encode INF, and casting NAN warns on PHP 8.5. + is_float($value) => is_nan($value) ? 'NAN' : (json_encode($value) ?: (string) $value), + default => (string) $value, + }, array_values($values)); + } + + /** + * Format parameters for a legacy validation rule string. + */ + protected function formatParameters(array $parameters): string + { + return implode(',', array_map(static function (mixed $parameter): string { + $parameter = (string) $parameter; + + return str_contains($parameter, ',') || str_contains($parameter, '"') + ? '"' . str_replace('"', '""', $parameter) . '"' + : $parameter; + }, $parameters)); + } +} diff --git a/src/validation/src/Rules/Confirmed.php b/src/validation/src/Rules/Confirmed.php new file mode 100644 index 0000000000..512b6b2a85 --- /dev/null +++ b/src/validation/src/Rules/Confirmed.php @@ -0,0 +1,33 @@ +confirmationField = $confirmationField; + + return $this; + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return $this->confirmationField === null ? ['confirmed'] : ['confirmed', $this->confirmationField]; + } +} diff --git a/src/validation/src/Rules/CurrentPassword.php b/src/validation/src/Rules/CurrentPassword.php new file mode 100644 index 0000000000..8dc6f78075 --- /dev/null +++ b/src/validation/src/Rules/CurrentPassword.php @@ -0,0 +1,33 @@ +guard = $guard; + + return $this; + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return $this->guard === null ? ['current_password'] : ['current_password', $this->guard]; + } +} diff --git a/src/validation/src/Rules/Date.php b/src/validation/src/Rules/Date.php index 174ae0fa4f..a45e0a9bad 100644 --- a/src/validation/src/Rules/Date.php +++ b/src/validation/src/Rules/Date.php @@ -147,6 +147,14 @@ public function betweenOrEqual(DateTimeInterface|string $from, DateTimeInterface return $this->afterOrEqual($from)->beforeOrEqual($to); } + /** + * Ensure the date is equal to the given date or date field. + */ + public function equals(DateTimeInterface|string $date): static + { + return $this->addRule('date_equals:' . $this->formatDate($date)); + } + /** * Add custom rules to the validation rules array. */ diff --git a/src/validation/src/Rules/DeclinedIf.php b/src/validation/src/Rules/DeclinedIf.php new file mode 100644 index 0000000000..c4472c4f47 --- /dev/null +++ b/src/validation/src/Rules/DeclinedIf.php @@ -0,0 +1,34 @@ +anotherField = $anotherField; + $this->values = is_array($values) ? $values : array_slice(func_get_args(), 1); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['declined_if', $this->anotherField, ...$this->formatConditionalValues($this->values)]; + } +} diff --git a/src/validation/src/Rules/Distinct.php b/src/validation/src/Rules/Distinct.php new file mode 100644 index 0000000000..97dbbe3550 --- /dev/null +++ b/src/validation/src/Rules/Distinct.php @@ -0,0 +1,43 @@ +options[] = 'strict'; + + return $this; + } + + /** + * Ignore case when comparing string values. + */ + public function ignoreCase(): static + { + $this->options[] = 'ignore_case'; + + return $this; + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['distinct', ...array_values(array_unique($this->options))]; + } +} diff --git a/src/validation/src/Rules/ExcludeWith.php b/src/validation/src/Rules/ExcludeWith.php new file mode 100644 index 0000000000..dcb745c3b2 --- /dev/null +++ b/src/validation/src/Rules/ExcludeWith.php @@ -0,0 +1,28 @@ +anotherField]; + } +} diff --git a/src/validation/src/Rules/ExcludeWithout.php b/src/validation/src/Rules/ExcludeWithout.php new file mode 100644 index 0000000000..66712f1b7d --- /dev/null +++ b/src/validation/src/Rules/ExcludeWithout.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['exclude_without', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/InArray.php b/src/validation/src/Rules/InArray.php new file mode 100644 index 0000000000..fe049393fc --- /dev/null +++ b/src/validation/src/Rules/InArray.php @@ -0,0 +1,28 @@ +otherField]; + } +} diff --git a/src/validation/src/Rules/InArrayKeys.php b/src/validation/src/Rules/InArrayKeys.php new file mode 100644 index 0000000000..5afde57d44 --- /dev/null +++ b/src/validation/src/Rules/InArrayKeys.php @@ -0,0 +1,31 @@ +keys = is_array($keys) ? $keys : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['in_array_keys', ...array_values($this->keys)]; + } +} diff --git a/src/validation/src/Rules/IpAddress.php b/src/validation/src/Rules/IpAddress.php new file mode 100644 index 0000000000..b046bfa61a --- /dev/null +++ b/src/validation/src/Rules/IpAddress.php @@ -0,0 +1,55 @@ + */ + private array $allowedVersions = [4, 6]; + + /** + * Set the IP version. + * + * @template TVersion of int + * + * @param TVersion $version + * + * @phpstan-return ($version is 4|6 ? static : never) + * + * @throws InvalidArgumentException + */ + public function version(int $version): static + { + if (! in_array($version, $this->allowedVersions, true)) { + throw new InvalidArgumentException(sprintf( + 'The provided IP version %d is invalid. Please use one of these: %s', + $version, + implode(', ', $this->allowedVersions), + )); + } + + $this->version = $version; + + return $this; + } + + /** + * Convert the rule to a validation string. + * + * @return 'ip'|'ipv4'|'ipv6' + */ + public function __toString(): string + { + return $this->version === null ? 'ip' : "ipv{$this->version}"; + } +} diff --git a/src/validation/src/Rules/MissingIf.php b/src/validation/src/Rules/MissingIf.php new file mode 100644 index 0000000000..41509a7286 --- /dev/null +++ b/src/validation/src/Rules/MissingIf.php @@ -0,0 +1,34 @@ +anotherField = $anotherField; + $this->values = is_array($values) ? $values : array_slice(func_get_args(), 1); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['missing_if', $this->anotherField, ...$this->formatConditionalValues($this->values)]; + } +} diff --git a/src/validation/src/Rules/MissingUnless.php b/src/validation/src/Rules/MissingUnless.php new file mode 100644 index 0000000000..709871d1f3 --- /dev/null +++ b/src/validation/src/Rules/MissingUnless.php @@ -0,0 +1,34 @@ +anotherField = $anotherField; + $this->values = is_array($values) ? $values : array_slice(func_get_args(), 1); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['missing_unless', $this->anotherField, ...$this->formatConditionalValues($this->values)]; + } +} diff --git a/src/validation/src/Rules/MissingWith.php b/src/validation/src/Rules/MissingWith.php new file mode 100644 index 0000000000..166cf8eff3 --- /dev/null +++ b/src/validation/src/Rules/MissingWith.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['missing_with', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/MissingWithAll.php b/src/validation/src/Rules/MissingWithAll.php new file mode 100644 index 0000000000..ea3475ba1d --- /dev/null +++ b/src/validation/src/Rules/MissingWithAll.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['missing_with_all', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/NotRegex.php b/src/validation/src/Rules/NotRegex.php new file mode 100644 index 0000000000..82dadbc0d1 --- /dev/null +++ b/src/validation/src/Rules/NotRegex.php @@ -0,0 +1,25 @@ +pattern}"; + } +} diff --git a/src/validation/src/Rules/PresentIf.php b/src/validation/src/Rules/PresentIf.php new file mode 100644 index 0000000000..9898c6ce52 --- /dev/null +++ b/src/validation/src/Rules/PresentIf.php @@ -0,0 +1,34 @@ +anotherField = $anotherField; + $this->values = is_array($values) ? $values : array_slice(func_get_args(), 1); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['present_if', $this->anotherField, ...$this->formatConditionalValues($this->values)]; + } +} diff --git a/src/validation/src/Rules/PresentUnless.php b/src/validation/src/Rules/PresentUnless.php new file mode 100644 index 0000000000..46d773d08a --- /dev/null +++ b/src/validation/src/Rules/PresentUnless.php @@ -0,0 +1,34 @@ +anotherField = $anotherField; + $this->values = is_array($values) ? $values : array_slice(func_get_args(), 1); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['present_unless', $this->anotherField, ...$this->formatConditionalValues($this->values)]; + } +} diff --git a/src/validation/src/Rules/PresentWith.php b/src/validation/src/Rules/PresentWith.php new file mode 100644 index 0000000000..dfc0dd9880 --- /dev/null +++ b/src/validation/src/Rules/PresentWith.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['present_with', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/PresentWithAll.php b/src/validation/src/Rules/PresentWithAll.php new file mode 100644 index 0000000000..1b1183c8ab --- /dev/null +++ b/src/validation/src/Rules/PresentWithAll.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['present_with_all', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/ProhibitedIfAccepted.php b/src/validation/src/Rules/ProhibitedIfAccepted.php new file mode 100644 index 0000000000..1f5f0fae6e --- /dev/null +++ b/src/validation/src/Rules/ProhibitedIfAccepted.php @@ -0,0 +1,28 @@ +field]; + } +} diff --git a/src/validation/src/Rules/ProhibitedIfDeclined.php b/src/validation/src/Rules/ProhibitedIfDeclined.php new file mode 100644 index 0000000000..ec9413cabc --- /dev/null +++ b/src/validation/src/Rules/ProhibitedIfDeclined.php @@ -0,0 +1,28 @@ +field]; + } +} diff --git a/src/validation/src/Rules/Prohibits.php b/src/validation/src/Rules/Prohibits.php new file mode 100644 index 0000000000..36fe21c708 --- /dev/null +++ b/src/validation/src/Rules/Prohibits.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['prohibits', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/Regex.php b/src/validation/src/Rules/Regex.php new file mode 100644 index 0000000000..4ace5cf1dd --- /dev/null +++ b/src/validation/src/Rules/Regex.php @@ -0,0 +1,25 @@ +pattern}"; + } +} diff --git a/src/validation/src/Rules/RequiredArrayKeys.php b/src/validation/src/Rules/RequiredArrayKeys.php new file mode 100644 index 0000000000..e3d658a953 --- /dev/null +++ b/src/validation/src/Rules/RequiredArrayKeys.php @@ -0,0 +1,31 @@ +keys = is_array($keys) ? $keys : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['required_array_keys', ...array_values($this->keys)]; + } +} diff --git a/src/validation/src/Rules/RequiredIfAccepted.php b/src/validation/src/Rules/RequiredIfAccepted.php new file mode 100644 index 0000000000..069d801e01 --- /dev/null +++ b/src/validation/src/Rules/RequiredIfAccepted.php @@ -0,0 +1,28 @@ +field]; + } +} diff --git a/src/validation/src/Rules/RequiredIfDeclined.php b/src/validation/src/Rules/RequiredIfDeclined.php new file mode 100644 index 0000000000..ea3e3b73a4 --- /dev/null +++ b/src/validation/src/Rules/RequiredIfDeclined.php @@ -0,0 +1,28 @@ +field]; + } +} diff --git a/src/validation/src/Rules/RequiredWith.php b/src/validation/src/Rules/RequiredWith.php new file mode 100644 index 0000000000..0946cf6c24 --- /dev/null +++ b/src/validation/src/Rules/RequiredWith.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['required_with', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/RequiredWithAll.php b/src/validation/src/Rules/RequiredWithAll.php new file mode 100644 index 0000000000..0be2d7a7a4 --- /dev/null +++ b/src/validation/src/Rules/RequiredWithAll.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['required_with_all', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/RequiredWithout.php b/src/validation/src/Rules/RequiredWithout.php new file mode 100644 index 0000000000..5079b16c04 --- /dev/null +++ b/src/validation/src/Rules/RequiredWithout.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['required_without', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/RequiredWithoutAll.php b/src/validation/src/Rules/RequiredWithoutAll.php new file mode 100644 index 0000000000..3aa9b60f6b --- /dev/null +++ b/src/validation/src/Rules/RequiredWithoutAll.php @@ -0,0 +1,31 @@ +fields = is_array($fields) ? $fields : func_get_args(); + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['required_without_all', ...array_values($this->fields)]; + } +} diff --git a/src/validation/src/Rules/Timezone.php b/src/validation/src/Rules/Timezone.php new file mode 100644 index 0000000000..6ae4e8b5fa --- /dev/null +++ b/src/validation/src/Rules/Timezone.php @@ -0,0 +1,28 @@ +arguments ?? [])]; + } +} diff --git a/src/validation/src/Rules/Url.php b/src/validation/src/Rules/Url.php new file mode 100644 index 0000000000..0f07150df6 --- /dev/null +++ b/src/validation/src/Rules/Url.php @@ -0,0 +1,38 @@ +protocols = $protocols; + + return $this; + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['url', ...array_values($this->protocols ?? [])]; + } +} diff --git a/src/validation/src/Rules/Uuid.php b/src/validation/src/Rules/Uuid.php new file mode 100644 index 0000000000..91c77f262c --- /dev/null +++ b/src/validation/src/Rules/Uuid.php @@ -0,0 +1,38 @@ + + */ + protected int|string|null $version = null; + + /** + * Specify the UUID version. + * + * @param 'max'|'nil'|int<0, 8> $version + */ + public function version(int|string $version): static + { + $this->version = $version; + + return $this; + } + + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return $this->version === null ? ['uuid'] : ['uuid', $this->version]; + } +} diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php index eb70000a41..ce88056849 100644 --- a/src/validation/src/ValidationRuleParser.php +++ b/src/validation/src/ValidationRuleParser.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Validation\CompilableRules; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Contracts\Validation\ValidationRule; use Hypervel\Support\Arr; @@ -153,6 +154,7 @@ public static function ruleReducesToString(mixed $rule): bool && ! $rule instanceof Closure && ! $rule instanceof ValidationRule && ! $rule instanceof RuleContract + && ! $rule instanceof ParameterizedRule && ! $rule instanceof CompilableRules && ! (($rule instanceof Exists || $rule instanceof Unique) && $rule->queryCallbacks() !== []); @@ -383,6 +385,10 @@ private function mergeRulesForAttributeInto(array &$results, string $attribute, */ public static function parse(array|object|string $rule): array { + if ($rule instanceof ParameterizedRule) { + $rule = $rule->toArray(); + } + if ($rule instanceof RuleContract || $rule instanceof CompilableRules) { return [$rule, []]; } diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 6306271aa1..a23981f508 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -286,6 +286,27 @@ class Validator implements ValidatorContract 'Unique', ]; + /** + * The position of the first literal parameter in dependent rules. + * + * @var array + */ + protected array $literalParameterOffsets = [ + 'AcceptedIf' => 1, + 'DeclinedIf' => 1, + 'ExcludeIf' => 1, + 'ExcludeUnless' => 1, + 'MissingIf' => 1, + 'MissingUnless' => 1, + 'PresentIf' => 1, + 'PresentUnless' => 1, + 'ProhibitedIf' => 1, + 'ProhibitedUnless' => 1, + 'RequiredIf' => 1, + 'RequiredUnless' => 1, + 'Unique' => 4, + ]; + /** * The validation rules that can exclude an attribute. * @@ -650,11 +671,7 @@ protected function preEvaluateExclusions(): array $passes = $exclusionOutcomes[$outcomeKey]; } else { if ($dependsOnOtherFields) { - $parameters = $this->replaceDotInParameters($parameters); - - if ($explicitKeys !== []) { - $parameters = $this->replaceAsterisksInParameters($parameters, $explicitKeys); - } + $parameters = $this->replaceFieldsInParameters($firstCheck->ruleName, $parameters, $explicitKeys); } $passes = match ($firstCheck->ruleName) { @@ -1133,13 +1150,9 @@ protected function validateAttribute(string $attribute, array|object|string $rul // First we will get the correct keys for the given attribute in case the field is nested in // an array. Then we determine if the given rule accepts other field names as parameters. - // If so, we will replace any asterisks found in the parameters with the correct keys. + // If so, resolve those field references without rewriting literal comparison values. if ($this->dependsOnOtherFields($rule)) { - $parameters = $this->replaceDotInParameters($parameters); - - if ($keys = $this->getExplicitKeys($attribute)) { - $parameters = $this->replaceAsterisksInParameters($parameters, $keys); - } + $parameters = $this->replaceFieldsInParameters($rule, $parameters, $this->getExplicitKeys($attribute)); } $value = $this->getValue($attribute); @@ -1231,6 +1244,25 @@ private function getImplicitAttributeMap(): array return $this->implicitAttributeMap; } + /** + * Resolve field parameters without rewriting literal comparison or database values. + */ + protected function replaceFieldsInParameters(string $rule, array $parameters, array $keys): array + { + // Literal values keep the existing string conversion, but are not attribute paths. + $literals = isset($this->literalParameterOffsets[$rule]) + ? array_map(strval(...), array_splice($parameters, $this->literalParameterOffsets[$rule])) + : []; + + $parameters = $this->replaceDotInParameters($parameters); + + if ($keys !== []) { + $parameters = $this->replaceAsterisksInParameters($parameters, $keys); + } + + return $literals === [] ? $parameters : [...$parameters, ...$literals]; + } + /** * Replace each escaped field parameter separator with its placeholder. */ @@ -1247,7 +1279,8 @@ protected function replaceDotInParameters(array $parameters): array protected function replaceAsterisksInParameters(array $parameters, array $keys): array { return array_map(function ($field) use ($keys) { - return vsprintf(str_replace('*', '%s', $field), $keys); + // Escape literal percent signs before introducing the wildcard placeholders. + return vsprintf(str_replace(['%', '*'], ['%%', '%s'], $field), $keys); }, $parameters); } @@ -1736,7 +1769,7 @@ public function addRules(array $rules): void /** * Add conditions to a given field based on a Closure. */ - public function sometimes(array|string $attribute, array|string $rules, callable $callback): static + public function sometimes(array|string $attribute, array|object|string $rules, callable $callback): static { $payload = new Fluent($this->data); diff --git a/tests/Data/Support/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index ecd78617f6..d90f240801 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -63,6 +63,7 @@ use Hypervel\Testbench\TestCase; use Hypervel\Tests\Data\Fixtures\SimpleData; use Hypervel\Validation\Factory as ValidationFactory; +use Hypervel\Validation\Rule as NativeRule; use Hypervel\Validation\Rules\Enum as EnumRule; use Hypervel\Validation\ValidationException; use Hypervel\Validation\Validator; @@ -981,6 +982,16 @@ public function testFinishedNestedDistinctItemsRejectNarrowerIdentity( ]); } + public function testFinishedItemsCannotBypassAParameterizedDistinctRule(): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessageIsOrContains('Cannot build the distinct rule for [items.*.name]'); + + ParameterizedDistinctParentDataFixture::getValidationRules([ + 'items' => [new ParameterizedDistinctChildDataFixture('duplicate'), ['name' => 'duplicate']], + ]); + } + /** * Test finished properties cannot narrow a nested distinct identity. */ @@ -3356,6 +3367,38 @@ public function __construct( } } +class ParameterizedDistinctChildDataFixture extends Data +{ + /** + * Create a child with a value checked across the collection. + */ + public function __construct(public string $name) + { + } + + /** + * Return the structured distinct rule. + */ + public static function rules(): array + { + return ['name' => [NativeRule::distinct()]]; + } +} + +class ParameterizedDistinctParentDataFixture extends Data +{ + /** + * Create the collection parent. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(ParameterizedDistinctChildDataFixture::class)] + public array $items, + ) { + } +} + #[MergeValidationRules] class NestedDistinctChildDataFixture extends Data { diff --git a/tests/Data/Support/Validation/FieldReferenceMappingTest.php b/tests/Data/Support/Validation/FieldReferenceMappingTest.php index 1ff31d662e..d747657dec 100644 --- a/tests/Data/Support/Validation/FieldReferenceMappingTest.php +++ b/tests/Data/Support/Validation/FieldReferenceMappingTest.php @@ -7,9 +7,15 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\PropertyForMorph; +use Hypervel\Data\Attributes\Validation\AcceptedIf; use Hypervel\Data\Attributes\Validation\After; +use Hypervel\Data\Attributes\Validation\Confirmed; +use Hypervel\Data\Attributes\Validation\DateEquals; +use Hypervel\Data\Attributes\Validation\DeclinedIf; use Hypervel\Data\Attributes\Validation\Different; use Hypervel\Data\Attributes\Validation\ExcludeIf; +use Hypervel\Data\Attributes\Validation\ExcludeUnless; +use Hypervel\Data\Attributes\Validation\ExcludeWithout; use Hypervel\Data\Attributes\Validation\GreaterThan; use Hypervel\Data\Attributes\Validation\InArray; use Hypervel\Data\Attributes\Validation\MissingIf; @@ -37,6 +43,8 @@ use Hypervel\Http\Request; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use Hypervel\Validation\Rules\AcceptedIf as AcceptedIfRule; +use Hypervel\Validation\Rules\RequiredWith as RequiredWithRule; use Hypervel\Validation\ValidationException; use PHPUnit\Framework\Attributes\DataProvider; @@ -73,6 +81,7 @@ public static function mappedClasses(): iterable yield 'property mapper' => [PropertyMappedData::class]; yield 'class mapper' => [ClassMappedData::class]; yield 'Rule string attribute' => [RuleMappedData::class]; + yield 'Rule builder attribute' => [BuilderMappedData::class]; yield 'attribute returned by rules' => [ClassRulesMappedData::class]; yield 'preserved input' => [PreservedMappedData::class]; yield 'explicit input name' => [InputNamedData::class]; @@ -108,6 +117,16 @@ public function testClassRuleStringsRemainLiteral(): void $this->assertSame(['required_with:lastName'], $rules['title']); } + public function testMappedConditionalBuilderChecksAllComparisonValues(): void + { + try { + ConditionalBuilderMappedData::validate(['account_type' => 'business', 'terms' => false]); + $this->fail('Expected the mapped conditional rule to require accepted terms.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('terms', $exception->errors()); + } + } + #[DataProvider('referenceRules')] public function testRuleHooksResolveFieldParametersWithoutChangingOtherParameters(ValidationRule $rule, string $expected): void { @@ -129,6 +148,8 @@ public function testRuleHooksResolveFieldParametersWithoutChangingOtherParameter */ public static function referenceRules(): iterable { + yield 'accepted if multiple values' => [new AcceptedIf('lastName', 'x', 'y'), 'accepted_if:last_name,x,y']; + yield 'declined if multiple values' => [new DeclinedIf('lastName', 'x', 'y'), 'declined_if:last_name,x,y']; yield 'required if' => [new RequiredIf('lastName', 'lastName'), 'required_if:last_name,lastName']; yield 'required with' => [new RequiredWith('lastName'), 'required_with:last_name']; yield 'required with all' => [new RequiredWithAll('lastName', 'confirmation'), 'required_with_all:last_name,confirmation']; @@ -139,11 +160,16 @@ public static function referenceRules(): iterable yield 'greater than' => [new GreaterThan('lastName'), 'gt:last_name']; yield 'in array' => [new InArray('lastName.*'), 'in_array:last_name.*']; yield 'exclude if' => [new ExcludeIf('lastName', 'x'), 'exclude_if:last_name,x']; + yield 'exclude if multiple values' => [new ExcludeIf('lastName', 'x', 'y'), 'exclude_if:last_name,x,y']; + yield 'exclude unless multiple values' => [new ExcludeUnless('lastName', 'x', 'y'), 'exclude_unless:last_name,x,y']; + yield 'exclude without multiple fields' => [new ExcludeWithout('lastName', 'confirmation'), 'exclude_without:last_name,confirmation']; + yield 'custom confirmation' => [new Confirmed('lastName'), 'confirmed:last_name']; yield 'prohibited if' => [new ProhibitedIf('lastName', 'x'), 'prohibited_if:last_name,x']; yield 'prohibits' => [new Prohibits('lastName', 'confirmation'), 'prohibits:last_name,confirmation']; yield 'present with' => [new PresentWith('lastName'), 'present_with:last_name']; yield 'missing if' => [new MissingIf('lastName', 'x'), 'missing_if:last_name,x']; yield 'date reference' => [new After(new FieldReference('lastName')), 'after:last_name']; + yield 'date equals reference' => [new DateEquals(new FieldReference('lastName')), 'date_equals:last_name']; } #[DataProvider('nestedPayloads')] @@ -271,6 +297,24 @@ class RuleMappedData extends Data public ?string $title; } +class BuilderMappedData extends Data +{ + #[MapInputName('last_name')] + public ?string $lastName; + + #[Rule(new RequiredWithRule('lastName'))] + public ?string $title; +} + +class ConditionalBuilderMappedData extends Data +{ + #[MapInputName('account_type')] + public string $accountType; + + #[Rule(new AcceptedIfRule('accountType', ['personal', 'business']))] + public bool $terms; +} + class ClassRulesMappedData extends Data { #[MapInputName('last_name')] diff --git a/tests/Data/Support/Validation/RuleDenormalizerTest.php b/tests/Data/Support/Validation/RuleDenormalizerTest.php index bba63d1ff0..c4111c8cf9 100644 --- a/tests/Data/Support/Validation/RuleDenormalizerTest.php +++ b/tests/Data/Support/Validation/RuleDenormalizerTest.php @@ -65,6 +65,9 @@ public static function rules(): iterable // Parameters yield 'boolean true parameter' => [new AcceptedIf('field', true), ['accepted_if:field,true']]; yield 'boolean false parameter' => [new AcceptedIf('field', false), ['accepted_if:field,false']]; + yield 'float parameter precision' => [new AcceptedIf('field', 1234567890.12345), ['accepted_if:field,1234567890.12345']]; + yield 'NAN parameter' => [new AcceptedIf('field', NAN), ['accepted_if:field,NAN']]; + yield 'INF parameter' => [new AcceptedIf('field', INF), ['accepted_if:field,INF']]; yield 'enum parameter' => [new AcceptedIf('field', DummyBackedEnum::BOO), ['accepted_if:field,boo']]; yield 'empty array parameter' => [new EndsWith, ['ends_with']]; yield 'array parameter' => [new EndsWith(['test', DummyBackedEnum::BOO]), ['ends_with:test,boo']]; diff --git a/tests/Data/Support/Validation/RuleNormalizerTest.php b/tests/Data/Support/Validation/RuleNormalizerTest.php index a5c00fc3dd..30829df83e 100644 --- a/tests/Data/Support/Validation/RuleNormalizerTest.php +++ b/tests/Data/Support/Validation/RuleNormalizerTest.php @@ -9,15 +9,18 @@ use Hypervel\Data\Attributes\Validation\AcceptedIf; use Hypervel\Data\Attributes\Validation\After; use Hypervel\Data\Attributes\Validation\BeforeOrEqual; +use Hypervel\Data\Attributes\Validation\Confirmed; use Hypervel\Data\Attributes\Validation\DateEquals; use Hypervel\Data\Attributes\Validation\DeclinedIf; use Hypervel\Data\Attributes\Validation\Dimensions; use Hypervel\Data\Attributes\Validation\ExcludeIf; +use Hypervel\Data\Attributes\Validation\ExcludeUnless; use Hypervel\Data\Attributes\Validation\Exists; use Hypervel\Data\Attributes\Validation\Min; use Hypervel\Data\Attributes\Validation\NotRegex; use Hypervel\Data\Attributes\Validation\Regex; use Hypervel\Data\Attributes\Validation\Required; +use Hypervel\Data\Attributes\Validation\RequiredWith; use Hypervel\Data\Attributes\Validation\Rule; use Hypervel\Data\Support\Validation\RuleDenormalizer; use Hypervel\Data\Support\Validation\RuleNormalizer; @@ -100,6 +103,14 @@ public function testCanMapHypervelRuleObjects(): void ); } + public function testCanMapParameterizedRulesWithFieldReferences(): void + { + $this->assertEquals( + [new RequiredWith('other')], + $this->mapper->execute(ValidationRule::requiredWith('other')), + ); + } + public function testCanMapACustomHypervelRuleObjects(): void { $rule = new class implements CustomRuleContract { @@ -140,12 +151,9 @@ public static function rulesNoAttributeCanHold(): iterable yield ['boolean:strict']; yield ['integer:strict']; yield ['numeric:strict']; - yield ['confirmed:repeat_field']; yield ['image:allow_svg']; yield ['timezone:per_country,US']; yield ['uuid:4']; - yield ['accepted_if:field,a,b']; - yield ['exclude_unless:field,a,b']; yield ['exists:users,email,status,"active"']; yield ['unique:users,email,5,uid,status,"active"']; yield ['dimensions:min_ratio=1/2,max_ratio=2/1']; @@ -169,8 +177,11 @@ public function testRebuildsTypedRulesWithTheirParametersAsWritten(string $rule, public static function typedRulesWithParameters(): iterable { yield ['accepted_if:status,1', AcceptedIf::class]; + yield ['accepted_if:field,a,b', AcceptedIf::class]; yield ['declined_if:status,0', DeclinedIf::class]; yield ['exclude_if:status,1', ExcludeIf::class]; + yield ['exclude_unless:field,a,b', ExcludeUnless::class]; + yield ['confirmed:repeat_field', Confirmed::class]; yield ['after:2020-01-01', After::class]; yield ['before_or_equal:2020-01-01 12:00', BeforeOrEqual::class]; yield ['date_equals:today', DateEquals::class]; @@ -193,6 +204,8 @@ public static function validatorRuleObjects(): iterable { yield 'closure' => [static function (string $attribute, mixed $value, Closure $fail): void {}]; yield 'fluent rule' => [ValidationRule::date()]; + yield 'UUID version' => [ValidationRule::uuid()->version(4)]; + yield 'combined distinct options' => [ValidationRule::distinct()->strict()->ignoreCase()]; yield 'nested rules' => [ValidationRule::forEach(static fn (): array => ['integer'])]; yield 'conditional rules' => [ValidationRule::when(true, ['min:3'])]; yield 'custom rule' => [new CustomHypervelRule]; diff --git a/tests/Data/Support/Validation/ValidationAccumulatorTest.php b/tests/Data/Support/Validation/ValidationAccumulatorTest.php index 04b647ca38..cfb55aeb6f 100644 --- a/tests/Data/Support/Validation/ValidationAccumulatorTest.php +++ b/tests/Data/Support/Validation/ValidationAccumulatorTest.php @@ -9,6 +9,7 @@ use Hypervel\Data\Support\Validation\ValidationAccumulator; use Hypervel\Data\Support\Validation\ValidationPath; use Hypervel\Tests\TestCase; +use Hypervel\Validation\Rule as ValidationRule; use Hypervel\Validation\Rules\Exists; use PHPUnit\Framework\Attributes\DataProvider; use Stringable; @@ -136,6 +137,20 @@ public function testComparesStringReducedRuleValuesByTheirRenderedForm(): void $this->assertFalse($accumulator->equals($other)); } + public function testComparesParameterizedRulesByTheirStructuredValues(): void + { + $accumulator = new ValidationAccumulator; + $accumulator->rules = ['website' => [ValidationRule::url()->protocols(['https'])]]; + $other = new ValidationAccumulator; + $other->rules = ['website' => [ValidationRule::url()->protocols(['https'])]]; + + $this->assertTrue($accumulator->equals($other)); + + $other->rules = ['website' => [ValidationRule::url()->protocols(['http'])]]; + + $this->assertFalse($accumulator->equals($other)); + } + /** * Test Validator rule objects compare by identity unless Validation stringifies them. */ diff --git a/tests/Data/ValidationTest.php b/tests/Data/ValidationTest.php index 9675a5f444..18fb45a9f2 100644 --- a/tests/Data/ValidationTest.php +++ b/tests/Data/ValidationTest.php @@ -2782,6 +2782,15 @@ public function testWillRemoveASometimesRuleGeneratedByAnOptionalTypeWhenManuall ]); } + public function testMergedBuilderRulesControlFieldPresence(): void + { + $this->assertSame([], TestDataWithMergedBuilderRules::validate([])); + + $this->expectException(ValidationException::class); + + TestDataWithMergedBuilderRules::validate(['otherProperty' => 'present']); + } + public function testItWillMergeValidationRules(): void { DataValidationAsserter::for(TestDataWithMergedRuleset::class) @@ -3707,6 +3716,20 @@ public static function rules(ValidationContext $context): array } } +#[MergeValidationRules] +class TestDataWithMergedBuilderRules extends Data +{ + public string $property; + + /** + * Require the property only when the other input is present. + */ + public static function rules(): array + { + return ['property' => [Rule::requiredWith('otherProperty')]]; + } +} + #[MergeValidationRules] class TestDataWithMergedRuleset extends Data { diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index 3889df54ce..5893068dee 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -7,6 +7,7 @@ use Closure; use DateTimeImmutable; use Hypervel\Contracts\Validation\ImplicitRule; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Http\UploadedFile; use Hypervel\Tests\TestCase; @@ -15,6 +16,7 @@ use Hypervel\Validation\PresenceVerifierInterface; use Hypervel\Validation\Rule; use Hypervel\Validation\Validator; +use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; @@ -419,6 +421,101 @@ public function testDependentRulesWithWildcardParameters(): void $this->assertFalse($v->errors()->has('items.0.end')); } + #[DataProvider('literalConditionalRules')] + public function testConditionalComparisonValuesRemainLiteral( + string|ParameterizedRule $rule, + string $literal, + array $field, + bool $passesWhenMatching, + ): void { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach ([$literal, 'different'] as $other) { + $validator = $this->makeValidator( + ['items' => [['other' => $other, ...$field]]], + ['items.*.value' => $rule], + validatorClass: $validatorClass, + ); + + $this->assertSame($other === $literal ? $passesWhenMatching : ! $passesWhenMatching, $validator->passes()); + } + } + } + + /** + * Provide conditional rules whose comparison values are not field references. + */ + public static function literalConditionalRules(): iterable + { + yield 'accepted if' => ['accepted_if:items.*.other,*', '*', ['value' => false], false]; + yield 'declined if' => ['declined_if:items.*.other,*', '*', ['value' => true], false]; + yield 'missing if' => ['missing_if:items.*.other,*', '*', ['value' => 'present'], false]; + yield 'missing unless' => ['missing_unless:items.*.other,*', '*', ['value' => 'present'], true]; + yield 'present if' => ['present_if:items.*.other,*', '*', [], false]; + yield 'present unless' => ['present_unless:items.*.other,*', '*', [], true]; + yield 'required if' => ['required_if:items.*.other,*', '*', [], false]; + yield 'required unless' => ['required_unless:items.*.other,*', '*', [], true]; + yield 'prohibited if' => ['prohibited_if:items.*.other,*', '*', ['value' => 'present'], false]; + yield 'prohibited unless' => ['prohibited_unless:items.*.other,*', '*', ['value' => 'present'], true]; + yield 'exclude if' => ['exclude_if:items.*.other,*|integer', '*', ['value' => 'invalid'], true]; + yield 'exclude unless' => ['exclude_unless:items.*.other,*|integer', '*', ['value' => 'invalid'], false]; + yield 'builder multiple asterisks' => [Rule::acceptedIf('items.*.other', '**'), '**', ['value' => false], false]; + yield 'builder escaped dot' => [Rule::acceptedIf('items.*.other', 'a\.b'), 'a\.b', ['value' => false], false]; + yield 'builder percent' => [Rule::acceptedIf('items.*.other', '100%'), '100%', ['value' => false], false]; + } + + public function testArrayFormConditionalValuesKeepTheirStringComparisonSemantics(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach ([[true, 'x'], [null, 0]] as [$comparison, $other]) { + $validator = $this->makeValidator( + ['other' => $other, 'value' => false], + ['value' => [['accepted_if', 'other', $comparison]]], + validatorClass: $validatorClass, + ); + + $this->assertTrue($validator->passes()); + } + } + } + + public function testDependentFieldsPreservePercentSigns(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $validator = $this->makeValidator( + ['items' => ['50%' => ['discount%' => 10]]], + ['items.*.value' => [Rule::requiredWith('items.*.discount%')]], + validatorClass: $validatorClass, + ); + + $this->assertFalse($validator->passes()); + $this->assertSame(['items.50%.value' => ['RequiredWith' => ['items.50%.discount%']]], $validator->failed()); + } + } + + public function testUniqueWhereValuesRemainLiteralWhileIgnoreFieldsResolve(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $verifier = m::mock(PresenceVerifierInterface::class); + $verifier->shouldReceive('getCount')->once()->with( + 'users', + 'email', + 'user@example.com', + '42', + 'id', + ['scope' => '*', 'path' => 'a\.b', 'discount' => '100%'], + )->andReturn(0); + $validator = $this->makeValidator( + ['items' => [['id' => 42, 'email' => 'user@example.com']]], + ['items.*.email' => [Rule::unique('users', 'email')->ignore('[items.*.id]') + ->where('scope', '*')->where('path', 'a\.b')->where('discount', '100%')]], + validatorClass: $validatorClass, + ); + $validator->setPresenceVerifier($verifier); + + $this->assertTrue($validator->passes()); + } + } + public function testBooleanStrictDelegated(): void { $v = $this->makeValidator(['flag' => true], ['flag' => 'boolean:strict']); @@ -781,6 +878,28 @@ public function testRulePlanCacheCannotCollideAcrossValidatorsWithRegexPipes(): $this->assertNotCount(count($firstPlan->checks), $secondPlan->checks); } + public function testCachedBuilderPlansPreserveStructuredParametersAfterBuilderMutation(): void + { + $compiledPlans = new ReflectionProperty(Validator::class, 'compiledPlans'); + $firstPlan = null; + + foreach (['secret', 'secret', 'different'] as $confirmation) { + $rule = Rule::confirmed()->customField('repeat,field|value'); + $validator = $this->makeValidator( + ['value' => 'secret', 'repeat,field|value' => $confirmation], + ['value' => [$rule]], + ); + + $this->assertSame($confirmation === 'secret', $validator->passes()); + + $plan = $compiledPlans->getValue($validator)['value']; + $firstPlan ??= $plan; + $this->assertSame($firstPlan, $plan); + + $rule->customField('another_field'); + } + } + #[DataProvider('guardedRuleCases')] public function testGuardedRuleDomainsMatchInCompiledAndDelegatedExecution( string $rule, diff --git a/tests/Validation/ValidationDateRuleTest.php b/tests/Validation/ValidationDateRuleTest.php index b1d5201e62..aca597be3b 100644 --- a/tests/Validation/ValidationDateRuleTest.php +++ b/tests/Validation/ValidationDateRuleTest.php @@ -30,6 +30,15 @@ public function testDateFormatRule(): void $this->assertSame('date_format:"d/m/Y"', (string) $rule); } + public function testDateEqualsRule(): void + { + $rule = Rule::date()->equals('2024-01-01'); + $this->assertSame('date|date_equals:"2024-01-01"', (string) $rule); + + $rule = Rule::date()->format('d/m/Y')->equals(CarbonImmutable::parse('2024-01-01')); + $this->assertSame('date_format:"d/m/Y"|date_equals:"01/01/2024"', (string) $rule); + } + public function testAfterTodayRule(): void { $rule = Rule::date()->afterToday(); diff --git a/tests/Validation/ValidationPreEvaluatedExclusionsTest.php b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php index aa4af3e56f..80680eef60 100644 --- a/tests/Validation/ValidationPreEvaluatedExclusionsTest.php +++ b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php @@ -13,6 +13,7 @@ use Hypervel\Translation\Translator; use Hypervel\Validation\Validator; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\TestWith; use Stringable; class ValidationPreEvaluatedExclusionsTest extends TestCase @@ -76,6 +77,27 @@ public function testExcludeIfResolvesEmptyWildcardKeys(): void ], $validator->validated()); } + #[TestWith(['*'], 'asterisk')] + #[TestWith(['**'], 'multiple asterisks')] + #[TestWith(['a\.b'], 'escaped dot')] + #[TestWith(['100%'], 'percent')] + public function testExclusionComparisonValuesRemainLiteral(string $literal): void + { + foreach ([Validator::class, DelegatedExclusionValidator::class] as $validatorClass) { + $validator = $this->makeValidator( + ['items' => [ + ['type' => $literal, 'secret' => 'invalid'], + ['type' => '0', 'secret' => 7], + ]], + ['items.*.secret' => "exclude_if:items.*.type,{$literal}|integer"], + $validatorClass, + ); + + $this->assertTrue($validator->passes()); + $this->assertSame(['items' => [1 => ['secret' => 7]]], $validator->validated()); + } + } + public function testActiveExclusionsResetBetweenPasses(): void { $validator = $this->makeValidator( diff --git a/tests/Validation/ValidationRuleBuilderMethodTest.php b/tests/Validation/ValidationRuleBuilderMethodTest.php new file mode 100644 index 0000000000..505839d269 --- /dev/null +++ b/tests/Validation/ValidationRuleBuilderMethodTest.php @@ -0,0 +1,366 @@ +assertSame('accepted', Rule::accepted()); + $this->assertSame('active_url', Rule::activeUrl()); + $this->assertSame('base64', Rule::base64()); + $this->assertSame('bail', Rule::bail()); + $this->assertSame('boolean', Rule::boolean()); + $this->assertSame('boolean', Rule::boolean(false)); + $this->assertSame('boolean:strict', Rule::boolean(true)); + $this->assertSame('declined', Rule::declined()); + $this->assertSame('exclude', Rule::exclude()); + $this->assertSame('filled', Rule::filled()); + $this->assertSame('hex_color', Rule::hexColor()); + $this->assertSame('json', Rule::json()); + $this->assertSame('list', Rule::list()); + $this->assertSame('mac_address', Rule::macAddress()); + $this->assertSame('missing', Rule::missing()); + $this->assertSame('nullable', Rule::nullable()); + $this->assertSame('present', Rule::present()); + $this->assertSame('prohibited', Rule::prohibited()); + $this->assertSame('required', Rule::required()); + $this->assertSame('sometimes', Rule::sometimes()); + $this->assertSame('ulid', Rule::ulid()); + } + + /** + * Test the conditional rule factory methods. + */ + public function testConditionalRuleBuilders(): void + { + $this->assertSame('accepted_if:user,null', (string) Rule::acceptedIf('user', null)); + $this->assertSame('declined_if:user,foo', (string) Rule::declinedIf('user', 'foo')); + $this->assertSame('missing_if:user,24', (string) Rule::missingIf('user', 24)); + $this->assertSame('missing_unless:user,24.5', (string) Rule::missingUnless('user', 24.5)); + $this->assertSame('present_if:user,foo', (string) Rule::presentIf('user', 'foo')); + $this->assertSame('present_unless:user,null', (string) Rule::presentUnless('user', null)); + $this->assertSame('accepted_if:user,foo,bar', (string) Rule::acceptedIf('user', 'foo', 'bar')); + $this->assertSame('declined_if:user,false,true', (string) Rule::declinedIf('user', false, true)); + $this->assertSame('missing_if:user,foo,bar', (string) Rule::missingIf('user', ['foo', 'bar'])); + $this->assertSame('missing_unless:user,true,false', (string) Rule::missingUnless('user', [true, false])); + $this->assertSame('present_if:user,foo,bar', (string) Rule::presentIf('user', 'foo', 'bar')); + $this->assertSame('present_unless:user,null,foo', (string) Rule::presentUnless('user', null, 'foo')); + } + + /** + * Test the additional rule factory methods. + */ + public function testAdditionalRuleBuilders(): void + { + $this->assertSame('confirmed', (string) Rule::confirmed()); + $this->assertSame('confirmed:email_confirmation', (string) Rule::confirmed()->customField('email_confirmation')); + $this->assertSame('confirmed:0', (string) Rule::confirmed()->customField('0')); + $this->assertSame('current_password', (string) Rule::currentPassword()); + $this->assertSame('current_password:api', (string) Rule::currentPassword()->guard('api')); + $this->assertSame('distinct', (string) Rule::distinct()); + $this->assertSame('distinct:strict', (string) Rule::distinct()->strict()); + $this->assertSame('distinct:ignore_case', (string) Rule::distinct()->ignoreCase()); + $this->assertSame('distinct:strict,ignore_case', (string) Rule::distinct()->strict()->ignoreCase()); + $this->assertSame('distinct:ignore_case,strict', (string) Rule::distinct()->ignoreCase()->strict()); + $this->assertSame('distinct:strict,ignore_case', (string) Rule::distinct()->strict()->ignoreCase()->strict()->ignoreCase()); + $this->assertSame('exclude_with:foo', (string) Rule::excludeWith('foo')); + $this->assertSame('exclude_without:foo', (string) Rule::excludeWithout('foo')); + $this->assertSame('exclude_without:foo,bar', (string) Rule::excludeWithout('foo', 'bar')); + $this->assertSame('in_array:foo', (string) Rule::inArray('foo')); + $this->assertSame('in_array_keys:foo,bar', (string) Rule::inArrayKeys(['foo', 'bar'])); + $this->assertSame('in_array_keys:foo,bar', (string) Rule::inArrayKeys('foo', 'bar')); + $this->assertSame('ip', (string) Rule::ip()); + $this->assertSame('ipv4', (string) Rule::ip()->version(4)); + $this->assertSame('ipv6', (string) Rule::ip()->version(6)); + $this->assertSame('missing_with:foo,bar', (string) Rule::missingWith(['foo', 'bar'])); + $this->assertSame('missing_with:foo,bar', (string) Rule::missingWith('foo', 'bar')); + $this->assertSame('missing_with_all:foo,bar', (string) Rule::missingWithAll(['foo', 'bar'])); + $this->assertSame('present_with:foo,bar', (string) Rule::presentWith(['foo', 'bar'])); + $this->assertSame('present_with:foo,bar', (string) Rule::presentWith('foo', 'bar')); + $this->assertSame('present_with_all:foo,bar', (string) Rule::presentWithAll(['foo', 'bar'])); + $this->assertSame('prohibited_if_accepted:foo', (string) Rule::prohibitedIfAccepted('foo')); + $this->assertSame('prohibited_if_declined:foo', (string) Rule::prohibitedIfDeclined('foo')); + $this->assertSame('prohibits:foo,bar', (string) Rule::prohibits(['foo', 'bar'])); + $this->assertSame('prohibits:foo,bar', (string) Rule::prohibits('foo', 'bar')); + $this->assertSame('regex:/^foo$/', (string) Rule::regex('/^foo$/')); + $this->assertSame('not_regex:/^foo$/', (string) Rule::notRegex('/^foo$/')); + $this->assertSame('required_array_keys:foo,bar', (string) Rule::requiredArrayKeys(['foo', 'bar'])); + $this->assertSame('required_array_keys:foo,bar', (string) Rule::requiredArrayKeys('foo', 'bar')); + $this->assertSame('required_if_accepted:foo', (string) Rule::requiredIfAccepted('foo')); + $this->assertSame('required_if_declined:foo', (string) Rule::requiredIfDeclined('foo')); + $this->assertSame('required_with:foo,bar', (string) Rule::requiredWith(['foo', 'bar'])); + $this->assertSame('required_with:foo,bar', (string) Rule::requiredWith('foo', 'bar')); + $this->assertSame('required_with_all:foo,bar', (string) Rule::requiredWithAll(['foo', 'bar'])); + $this->assertSame('required_without:foo,bar', (string) Rule::requiredWithout(['foo', 'bar'])); + $this->assertSame('required_without_all:foo,bar', (string) Rule::requiredWithoutAll(['foo', 'bar'])); + $this->assertSame('required_without_all:foo,bar', (string) Rule::requiredWithoutAll('foo', 'bar')); + $this->assertSame('timezone:per_country,US', (string) Rule::timezone(['per_country', 'US'])); + $this->assertSame('url:http,https', (string) Rule::url()->protocols(['http', 'https'])); + $this->assertSame('uuid:4', (string) Rule::uuid()->version(4)); + $this->assertSame('uuid:0', (string) Rule::uuid()->version(0)); + $this->assertSame('uuid:nil', (string) Rule::uuid()->version('nil')); + $this->assertSame('uuid:max', (string) Rule::uuid()->version('max')); + } + + /** + * Test that conditional rule parameters survive parsing without serialization. + */ + #[DataProvider('conditionalRuleParametersProvider')] + public function testConditionalRuleParametersArePreserved(string $method, string $value): void + { + $rule = Rule::$method('other\",field', $value); + + $this->assertSame(['other\",field', $value], ValidationRuleParser::parse($rule)[1]); + } + + /** + * Provide conditional rule methods and values requiring escaping. + */ + public static function conditionalRuleParametersProvider(): iterable + { + foreach (['acceptedIf', 'declinedIf', 'missingIf', 'missingUnless', 'presentIf', 'presentUnless'] as $method) { + foreach (['foo,bar', '"quoted"', 'foo"bar,baz', 'foo|bar', 'foo,bar\\', 'foo\",bar', '\",admin,true', ''] as $value) { + yield "{$method}: {$value}" => [$method, $value]; + } + } + } + + /** + * Test that escaped values trigger conditional validation. + */ + public function testConditionalRulesValidateEscapedValues(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + foreach ([ + ['acceptedIf', ['other' => 'foo,bar', 'field' => false]], + ['declinedIf', ['other' => 'foo,bar', 'field' => true]], + ['missingIf', ['other' => 'foo,bar', 'field' => 'value']], + ['missingUnless', ['other' => 'foo', 'field' => 'value']], + ['presentIf', ['other' => 'foo,bar']], + ['presentUnless', ['other' => 'foo']], + ] as [$method, $data]) { + $validator = new Validator($translator, $data, ['field' => [Rule::$method('other', 'foo,bar')]]); + + $this->assertFalse($validator->passes(), $method); + } + } + + /** + * Test that CSV metacharacters cannot bypass conditional validation. + */ + #[DataProvider('conditionalRuleParametersProvider')] + public function testConditionalRuleParametersCannotBypassValidation(string $method, string $value): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $rule = Rule::$method('other', $value); + $data = ['other' => $value]; + + if (! str_starts_with($method, 'present')) { + $data['field'] = match ($method) { + 'acceptedIf' => false, + 'declinedIf' => true, + default => 'value', + }; + } + + foreach ([$rule, [$rule], [Rule::when(true, [$rule])]] as $rules) { + $this->assertSame(str_ends_with($method, 'Unless'), (new Validator($translator, $data, ['field' => $rules]))->passes()); + + $differentData = array_replace($data, ['other' => 'different:' . $value]); + + $this->assertSame(! str_ends_with($method, 'Unless'), (new Validator($translator, $differentData, ['field' => $rules]))->passes()); + } + } + + /** + * Test that wildcard and nested rules preserve conditional parameters. + */ + public function testConditionalRuleParametersArePreservedForWildcardAndNestedRules(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $value = 'foo\",bar'; + $data = ['items' => [['other' => $value, 'terms' => false]]]; + $rule = Rule::acceptedIf('items.*.other', $value); + + foreach ([$rule, [$rule], Rule::forEach(fn () => [$rule])] as $rules) { + $validator = new Validator($translator, $data, ['items.*.terms' => $rules]); + + $this->assertFalse($validator->passes()); + $this->assertSame(['items.0.terms' => ['validation.accepted_if']], $validator->errors()->toArray()); + } + } + + /** + * Test that field and key lists survive parsing. + */ + public function testFieldAndKeyParametersAreEscaped(): void + { + foreach ([ + 'excludeWithout', 'inArrayKeys', 'missingWith', 'missingWithAll', + 'presentWith', 'presentWithAll', 'prohibits', 'requiredArrayKeys', + 'requiredWith', 'requiredWithAll', 'requiredWithout', 'requiredWithoutAll', + ] as $method) { + $parameters = ['foo,bar', '"quoted"', 'foo,bar\\', 'foo\",bar']; + + $this->assertSame($parameters, ValidationRuleParser::parse(Rule::$method($parameters))[1]); + $this->assertSame($parameters, ValidationRuleParser::parse(Rule::$method(...$parameters))[1]); + } + + foreach ([ + 'excludeWith', 'inArray', 'prohibitedIfAccepted', + 'prohibitedIfDeclined', 'requiredIfAccepted', 'requiredIfDeclined', + ] as $method) { + $this->assertSame(['foo\",bar'], ValidationRuleParser::parse(Rule::$method('foo\",bar'))[1]); + } + + $this->assertSame(['foo\",bar'], ValidationRuleParser::parse(Rule::confirmed()->customField('foo\",bar'))[1]); + $this->assertSame(['foo\",bar'], ValidationRuleParser::parse(Rule::currentPassword()->guard('foo\",bar'))[1]); + } + + /** + * Test that array key builders validate literal comma-containing keys. + */ + public function testArrayKeyRulesValidateEscapedKeys(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + foreach (['requiredArrayKeys', 'inArrayKeys'] as $method) { + foreach (['foo,bar', 'foo,bar\\', 'foo\",bar'] as $key) { + $rules = ['settings' => [Rule::$method([$key])]]; + + $this->assertTrue((new Validator($translator, ['settings' => [$key => 1]], $rules))->passes()); + $this->assertFalse((new Validator($translator, ['settings' => ['foo' => 1, 'bar' => 1]], $rules))->passes()); + } + } + } + + /** + * Test that associative parameter arrays become positional parameter lists. + */ + public function testAssociativeRuleParametersAreNormalized(): void + { + $this->assertSame(['other', 'foo,bar'], ValidationRuleParser::parse(Rule::acceptedIf('other', ['value' => 'foo,bar']))[1]); + $this->assertSame(['foo,bar'], ValidationRuleParser::parse(Rule::requiredWith(['field' => 'foo,bar']))[1]); + $this->assertSame(['foo,bar'], ValidationRuleParser::parse(Rule::requiredArrayKeys(['key' => 'foo,bar']))[1]); + } + + /** + * Test that numeric conditional values retain their existing string representation. + */ + public function testConditionalRulesPreserveScalarSemantics(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + foreach (['acceptedIf', 'declinedIf', 'missingIf', 'missingUnless', 'presentIf', 'presentUnless'] as $method) { + $this->assertSame(['other', 'true', 'false', 'null', '1', '1.5'], ValidationRuleParser::parse(Rule::$method('other', [true, false, null, 1, 1.5]))[1]); + } + + foreach ([true, false, null, 1, 1.5] as $value) { + $this->assertFalse((new Validator($translator, ['other' => $value, 'terms' => false], [ + 'terms' => [Rule::acceptedIf('other', $value)], + ]))->passes()); + } + } + + public function testConditionalRuleBuildersPreserveFloatComparisonPrecision(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + foreach ([ + ['acceptedIf', ['field' => false]], + ['declinedIf', ['field' => true]], + ['missingIf', ['field' => 'value']], + ['missingUnless', ['field' => 'value']], + ['presentIf', []], + ['presentUnless', []], + ] as [$method, $data]) { + foreach ([ + [0.1 + 0.2, 0.1 + 0.2, true], + [1234567890.12345, '1234567890.12345', true], + [1.1 * 3, '3.3', false], + ] as [$comparison, $other, $matches]) { + $validator = new Validator($translator, ['other' => $other, ...$data], [ + 'field' => [Rule::$method('other', $comparison)], + ]); + + $passes = str_ends_with($method, 'Unless'); + $this->assertSame($matches ? $passes : ! $passes, $validator->passes(), $method); + } + } + } + + public function testAcceptedIfNonFiniteValuesDoNotMatchEmptyOrFalseInput(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + foreach ([[NAN, 'NAN'], [INF, 'INF'], [-INF, '-INF']] as [$value, $expected]) { + $rule = Rule::acceptedIf('other', $value); + + $this->assertSame(['other', $expected], ValidationRuleParser::parse($rule)[1]); + + foreach (['', false] as $other) { + $this->assertTrue((new Validator($translator, ['other' => $other, 'terms' => false], [ + 'terms' => [$rule], + ]))->passes()); + } + } + } + + /** + * Test that distinct options remain active when chained. + */ + public function testDistinctRulesValidateCombinedOptions(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + foreach ([Rule::distinct()->strict()->ignoreCase(), Rule::distinct()->ignoreCase()->strict()] as $rule) { + $rules = ['items.*' => [$rule]]; + + $this->assertTrue((new Validator($translator, ['items' => [1, true]], $rules))->passes()); + $this->assertFalse((new Validator($translator, ['items' => ['foo', 'FOO']], $rules))->passes()); + $this->assertFalse((new Validator($translator, ['items' => [1, 1]], $rules))->passes()); + } + } + + /** + * Test that zero is retained as a custom confirmation field. + */ + public function testConfirmedRuleValidatesZeroField(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $rules = ['password' => [Rule::confirmed()->customField('0')]]; + + $this->assertTrue((new Validator($translator, ['password' => 'secret', '0' => 'secret'], $rules))->passes()); + $this->assertFalse((new Validator($translator, ['password' => 'secret', '0' => 'different'], $rules))->passes()); + } + + /** + * Test that strict boolean validation rejects non-boolean values. + */ + public function testBooleanRuleValidatesStrictly(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + + foreach ([true, false, 0, 1, '0', '1'] as $value) { + $data = ['field' => $value]; + + $this->assertTrue((new Validator($translator, $data, ['field' => [Rule::boolean()]]))->passes()); + $this->assertSame(is_bool($value), (new Validator($translator, $data, ['field' => [Rule::boolean(true)]]))->passes()); + } + } +} diff --git a/tests/Validation/ValidationRuleCompilerTest.php b/tests/Validation/ValidationRuleCompilerTest.php index c66c6ed189..bba46f3232 100644 --- a/tests/Validation/ValidationRuleCompilerTest.php +++ b/tests/Validation/ValidationRuleCompilerTest.php @@ -13,6 +13,7 @@ use Hypervel\Validation\Enums\CheckType; use Hypervel\Validation\InlineCheck; use Hypervel\Validation\RuleCompiler; +use Hypervel\Validation\Rules\Confirmed; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Unique; use Stringable; @@ -528,6 +529,7 @@ public function testEmptyArrayRuleSkipped(): void public function testCompileAllDelegatedProducesOnlyDelegatedChecks(): void { + $builder = new Confirmed; $plan = RuleCompiler::compileAllDelegated([ 'nullable', 'bail', @@ -535,6 +537,7 @@ public function testCompileAllDelegatedProducesOnlyDelegatedChecks(): void 'required', 'string', 'max:255', + $builder, ]); foreach ($plan->checks as $check) { @@ -545,7 +548,8 @@ public function testCompileAllDelegatedProducesOnlyDelegatedChecks(): void $this->assertTrue($plan->nullable); $this->assertTrue($plan->bail); $this->assertTrue($plan->sometimes); - $this->assertCount(6, $plan->checks); + $this->assertCount(7, $plan->checks); + $this->assertSame($builder, $plan->checks[6]->originalRule); } public function testMultipleOfLiteralInlines(): void diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index abacb94776..214ec85241 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -4,17 +4,46 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Support\Fluent; use Hypervel\Tests\TestCase; use Hypervel\Validation\NestedRules; use Hypervel\Validation\Rule; use Hypervel\Validation\ValidationRuleParser; +use LogicException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; class ValidationRuleParserTest extends TestCase { + public function testParameterizedRulesAreNotStringified(): void + { + $rule = new class implements ParameterizedRule { + /** + * Get the rule name followed by its parameters. + */ + public function toArray(): array + { + return ['accepted_if', 'other', 'foo\",bar']; + } + + /** + * Reject serialization to verify the structured parsing path. + */ + public function __toString(): string + { + throw new LogicException('This rule must not be stringified.'); + } + }; + + $parser = new ValidationRuleParser([]); + + $this->assertSame(['field' => [$rule]], $parser->explode(['field' => $rule])->rules); + $this->assertSame(['field' => [$rule]], $parser->explode(['field' => [$rule]])->rules); + $this->assertSame(['AcceptedIf', ['other', 'foo\",bar']], ValidationRuleParser::parse($rule)); + } + public function testConditionalRulesAreProperlyExpandedAndFiltered(): void { $isAdmin = true; diff --git a/tests/Validation/ValidationRulePlanCacheTest.php b/tests/Validation/ValidationRulePlanCacheTest.php index 7f9c76c22e..eaef4e76b8 100644 --- a/tests/Validation/ValidationRulePlanCacheTest.php +++ b/tests/Validation/ValidationRulePlanCacheTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Validation\ParameterizedRule; use Hypervel\Tests\TestCase; use Hypervel\Validation\AttributePlan; use Hypervel\Validation\Enums\CheckType; @@ -11,10 +12,12 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\RuleCompiler; use Hypervel\Validation\RulePlanCache; +use Hypervel\Validation\Rules\Concerns\FormatsParameters; use Hypervel\Validation\ValidationRuleParser; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use stdClass; +use WeakReference; class ValidationRulePlanCacheTest extends TestCase { @@ -68,6 +71,49 @@ public function testCanonicalFluentRulesShareCachedPlans(): void $this->assertSame($plan, RulePlanCache::get($secondRules)); } + public function testParameterizedRulesSharePlansWithoutRetainingMutableBuilders(): void + { + $rule = Rule::confirmed()->customField('first'); + $reference = WeakReference::create($rule); + $firstPlan = $this->compile([$rule]); + RulePlanCache::put([$rule], $firstPlan); + + $this->assertSame($firstPlan, RulePlanCache::get([Rule::confirmed()->customField('first')])); + + $rule->customField('second'); + + $this->assertNull(RulePlanCache::get([$rule])); + + $secondPlan = $this->compile([$rule]); + RulePlanCache::put([$rule], $secondPlan); + unset($rule); + + $this->assertNull($reference->get()); + $this->assertSame($firstPlan, RulePlanCache::get([Rule::confirmed()->customField('first')])); + $this->assertSame($secondPlan, RulePlanCache::get([Rule::confirmed()->customField('second')])); + $this->assertSame(['Confirmed', 'first'], $firstPlan->checks[0]->originalRule); + $this->assertSame(['Confirmed', 'second'], $secondPlan->checks[0]->originalRule); + } + + public function testParameterizedRulesWithCallbacksRemainUncached(): void + { + $rule = new class implements ParameterizedRule { + use FormatsParameters; + + /** + * Return a custom rule with an executable parameter. + */ + public function toArray(): array + { + return ['custom', static fn (): bool => true]; + } + }; + + RulePlanCache::put([$rule], $this->compile([$rule])); + + $this->assertNull(RulePlanCache::get([$rule])); + } + public function testFlushStateClearsCache(): void { $rules = ['required']; diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 56545f74f7..47a7a77abd 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -7453,6 +7453,12 @@ public function testSometimesAddingRules(): void }); $this->assertEquals(['x' => ['Required', 'Confirmed']], $v->getRules()); + $v = new Validator($trans, ['x' => 'foo'], []); + $v->sometimes('x', ValidationRule::confirmed(), static fn (): bool => true); + + $this->assertTrue($v->fails()); + $this->assertSame(['x' => ['Confirmed' => []]], $v->failed()); + $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => ''], ['y' => 'Required']); $v->sometimes('x', 'Required', function ($i) {