From 4cc7fd6968c48bdf73d0c16c1d4139a292f43e85 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:40:30 +0000 Subject: [PATCH 1/4] Port validation rule builders and preserve compiled plan reuse Port Laravel framework PR #61833 at dc3455f165ea77d793d3a738b1a28bbae41a9c35, reconciled with the frozen 13.x source target 5d7797528723107cd4f495326838a7d7d67fbb20. Add the remaining Rule factories, parameterized builders, Date::equals, and structured parsing without reducing parameter values to strings. Preserve Hypervel's native types, CSV quoting and immutable date behavior. Integrate structured rules with Data's presence and distinct checks, field-reference mapping and collection rule comparison. Extend the existing conditional attributes to retain multiple comparison values, ExcludeWithout to retain multiple fields, Confirmed to accept a custom field, and DateEquals to accept an explicit FieldReference. Allow rule objects in Validator::sometimes and its contract. Reuse the existing bounded compiled-plan cache for builders containing only scalar values. Base plans keep parsed values rather than mutable builder objects; subclass hooks retain their original rule objects. Executable and non-scalar rules remain uncached. This preserves plan reuse for expanded array fields without adding another cache mechanism. Port the corresponding validation documentation from Laravel docs 73e61270287e4aaa1cecd77cd66aa6ef4f499828 and clarify Data's mapped field references versus literal rules returned by hooks. Validation: every changed test file, the complete Validation suite, Data's validation support suite and ValidationTest, formatting, and full source/type/skeleton static analysis pass. Regression coverage verifies conditional presence, mapped builder parameters, finished-item distinct checks, plan reuse, mutation isolation and builder release. Final cache guard simplification passes its test file and targeted analysis. Quick idle-machine comparisons show reusable builders reducing validation time by roughly 17-26%, including four independent worker processes. Continuously changing rules add about 0.04 ms per 20-field validation; cache growth remains bounded by the existing limit. --- .../src/Validation/ParameterizedRule.php | 15 + src/contracts/src/Validation/Validator.php | 2 +- .../src/Attributes/Validation/AcceptedIf.php | 5 + .../src/Attributes/Validation/Confirmed.php | 13 +- .../src/Attributes/Validation/DateEquals.php | 3 +- .../src/Attributes/Validation/DeclinedIf.php | 5 + .../src/Attributes/Validation/ExcludeIf.php | 5 + .../Attributes/Validation/ExcludeUnless.php | 5 + .../Attributes/Validation/ExcludeWithout.php | 6 + .../Validation/DataValidationCompiler.php | 25 +- .../src/Support/Validation/RuleNormalizer.php | 23 +- .../Validation/ValidationAccumulator.php | 5 + .../Validation/ValidationRuleFactory.php | 7 +- src/docs/data-objects.md | 2 +- src/docs/validation.md | 93 ++++ src/validation/src/DelegatedCheck.php | 2 +- src/validation/src/Rule.php | 461 +++++++++++++++++- src/validation/src/RuleCompiler.php | 5 +- src/validation/src/RulePlanCache.php | 19 +- src/validation/src/Rules/AcceptedIf.php | 42 ++ .../src/Rules/Concerns/FormatsParameters.php | 33 ++ src/validation/src/Rules/Confirmed.php | 33 ++ src/validation/src/Rules/CurrentPassword.php | 33 ++ src/validation/src/Rules/Date.php | 8 + src/validation/src/Rules/DeclinedIf.php | 41 ++ src/validation/src/Rules/Distinct.php | 43 ++ src/validation/src/Rules/ExcludeWith.php | 28 ++ src/validation/src/Rules/ExcludeWithout.php | 31 ++ src/validation/src/Rules/InArray.php | 28 ++ src/validation/src/Rules/InArrayKeys.php | 31 ++ src/validation/src/Rules/IpAddress.php | 55 +++ src/validation/src/Rules/MissingIf.php | 41 ++ src/validation/src/Rules/MissingUnless.php | 41 ++ src/validation/src/Rules/MissingWith.php | 31 ++ src/validation/src/Rules/MissingWithAll.php | 31 ++ src/validation/src/Rules/NotRegex.php | 25 + src/validation/src/Rules/PresentIf.php | 41 ++ src/validation/src/Rules/PresentUnless.php | 41 ++ src/validation/src/Rules/PresentWith.php | 31 ++ src/validation/src/Rules/PresentWithAll.php | 31 ++ .../src/Rules/ProhibitedIfAccepted.php | 28 ++ .../src/Rules/ProhibitedIfDeclined.php | 28 ++ src/validation/src/Rules/Prohibits.php | 31 ++ src/validation/src/Rules/Regex.php | 25 + .../src/Rules/RequiredArrayKeys.php | 31 ++ .../src/Rules/RequiredIfAccepted.php | 28 ++ .../src/Rules/RequiredIfDeclined.php | 28 ++ src/validation/src/Rules/RequiredWith.php | 31 ++ src/validation/src/Rules/RequiredWithAll.php | 31 ++ src/validation/src/Rules/RequiredWithout.php | 31 ++ .../src/Rules/RequiredWithoutAll.php | 31 ++ src/validation/src/Rules/Timezone.php | 28 ++ src/validation/src/Rules/Url.php | 38 ++ src/validation/src/Rules/Uuid.php | 38 ++ src/validation/src/ValidationRuleParser.php | 6 + src/validation/src/Validator.php | 2 +- .../Support/Validation/DataValidatorTest.php | 43 ++ .../Validation/FieldReferenceMappingTest.php | 44 ++ .../Support/Validation/RuleNormalizerTest.php | 19 +- .../Validation/ValidationAccumulatorTest.php | 15 + tests/Data/ValidationTest.php | 23 + .../ValidationCompiledExecutionTest.php | 22 + tests/Validation/ValidationDateRuleTest.php | 9 + .../ValidationRuleBuilderMethodTest.php | 322 ++++++++++++ .../Validation/ValidationRuleCompilerTest.php | 6 +- tests/Validation/ValidationRuleParserTest.php | 29 ++ .../ValidationRulePlanCacheTest.php | 46 ++ tests/Validation/ValidationValidatorTest.php | 6 + 68 files changed, 2397 insertions(+), 42 deletions(-) create mode 100644 src/contracts/src/Validation/ParameterizedRule.php create mode 100644 src/validation/src/Rules/AcceptedIf.php create mode 100644 src/validation/src/Rules/Concerns/FormatsParameters.php create mode 100644 src/validation/src/Rules/Confirmed.php create mode 100644 src/validation/src/Rules/CurrentPassword.php create mode 100644 src/validation/src/Rules/DeclinedIf.php create mode 100644 src/validation/src/Rules/Distinct.php create mode 100644 src/validation/src/Rules/ExcludeWith.php create mode 100644 src/validation/src/Rules/ExcludeWithout.php create mode 100644 src/validation/src/Rules/InArray.php create mode 100644 src/validation/src/Rules/InArrayKeys.php create mode 100644 src/validation/src/Rules/IpAddress.php create mode 100644 src/validation/src/Rules/MissingIf.php create mode 100644 src/validation/src/Rules/MissingUnless.php create mode 100644 src/validation/src/Rules/MissingWith.php create mode 100644 src/validation/src/Rules/MissingWithAll.php create mode 100644 src/validation/src/Rules/NotRegex.php create mode 100644 src/validation/src/Rules/PresentIf.php create mode 100644 src/validation/src/Rules/PresentUnless.php create mode 100644 src/validation/src/Rules/PresentWith.php create mode 100644 src/validation/src/Rules/PresentWithAll.php create mode 100644 src/validation/src/Rules/ProhibitedIfAccepted.php create mode 100644 src/validation/src/Rules/ProhibitedIfDeclined.php create mode 100644 src/validation/src/Rules/Prohibits.php create mode 100644 src/validation/src/Rules/Regex.php create mode 100644 src/validation/src/Rules/RequiredArrayKeys.php create mode 100644 src/validation/src/Rules/RequiredIfAccepted.php create mode 100644 src/validation/src/Rules/RequiredIfDeclined.php create mode 100644 src/validation/src/Rules/RequiredWith.php create mode 100644 src/validation/src/Rules/RequiredWithAll.php create mode 100644 src/validation/src/Rules/RequiredWithout.php create mode 100644 src/validation/src/Rules/RequiredWithoutAll.php create mode 100644 src/validation/src/Rules/Timezone.php create mode 100644 src/validation/src/Rules/Url.php create mode 100644 src/validation/src/Rules/Uuid.php create mode 100644 tests/Validation/ValidationRuleBuilderMethodTest.php 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/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..1ba2701550 --- /dev/null +++ b/src/validation/src/Rules/AcceptedIf.php @@ -0,0 +1,42 @@ + + */ + 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 + { + $values = array_map( + static fn (string|int|float|bool|null $value): string|false => is_string($value) ? $value : json_encode($value), + $this->values, + ); + + return ['accepted_if', $this->anotherField, ...array_values($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..60747a2421 --- /dev/null +++ b/src/validation/src/Rules/Concerns/FormatsParameters.php @@ -0,0 +1,33 @@ +toArray(); + $rule = array_shift($parameters); + + return $rule . ($parameters ? ':' . $this->formatParameters($parameters) : ''); + } + + /** + * 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..acd845c5a6 --- /dev/null +++ b/src/validation/src/Rules/DeclinedIf.php @@ -0,0 +1,41 @@ +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 + { + $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { + is_null($value) => 'null', + $value === true => 'true', + $value === false => 'false', + default => (string) $value, + }, $this->values); + + return ['declined_if', $this->anotherField, ...array_values($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..3697931e51 --- /dev/null +++ b/src/validation/src/Rules/MissingIf.php @@ -0,0 +1,41 @@ +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 + { + $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { + is_null($value) => 'null', + $value === true => 'true', + $value === false => 'false', + default => (string) $value, + }, $this->values); + + return ['missing_if', $this->anotherField, ...array_values($values)]; + } +} diff --git a/src/validation/src/Rules/MissingUnless.php b/src/validation/src/Rules/MissingUnless.php new file mode 100644 index 0000000000..acfdba41c0 --- /dev/null +++ b/src/validation/src/Rules/MissingUnless.php @@ -0,0 +1,41 @@ +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 + { + $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { + is_null($value) => 'null', + $value === true => 'true', + $value === false => 'false', + default => (string) $value, + }, $this->values); + + return ['missing_unless', $this->anotherField, ...array_values($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..e7e3c41d88 --- /dev/null +++ b/src/validation/src/Rules/PresentIf.php @@ -0,0 +1,41 @@ +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 + { + $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { + is_null($value) => 'null', + $value === true => 'true', + $value === false => 'false', + default => (string) $value, + }, $this->values); + + return ['present_if', $this->anotherField, ...array_values($values)]; + } +} diff --git a/src/validation/src/Rules/PresentUnless.php b/src/validation/src/Rules/PresentUnless.php new file mode 100644 index 0000000000..ce32b7bea8 --- /dev/null +++ b/src/validation/src/Rules/PresentUnless.php @@ -0,0 +1,41 @@ +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 + { + $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { + is_null($value) => 'null', + $value === true => 'true', + $value === false => 'false', + default => (string) $value, + }, $this->values); + + return ['present_unless', $this->anotherField, ...array_values($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..cb297fcbd6 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -1736,7 +1736,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/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..fc2917e57e 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -781,6 +781,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/ValidationRuleBuilderMethodTest.php b/tests/Validation/ValidationRuleBuilderMethodTest.php new file mode 100644 index 0000000000..28403c4ee8 --- /dev/null +++ b/tests/Validation/ValidationRuleBuilderMethodTest.php @@ -0,0 +1,322 @@ +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()); + } + } + + /** + * 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) { From 953d4c039043e875e710fa73a10162df8e392383 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:37:44 +0000 Subject: [PATCH 2/4] Preserve literal values in dependent validation rules Dependent-rule normalization treated every parameter as a field path. That replaced literal asterisks with array indexes, encoded escaped dots inside comparison values and database constraints, and interpreted percent signs as sprintf syntax. Conditional rules could silently miss their condition, unique checks could query the wrong scope, and valid values could raise formatting errors. Share field-parameter normalization between ordinary validation and exclusion pre-evaluation. Preserve string conversion for literal tails, but limit field rewriting to the reference portion of each conditional rule and the first four Unique parameters. Other dependent rules retain their existing behavior and extension signatures. Escape literal percent signs before introducing wildcard placeholders. Regression tests cover each affected conditional rule, both execution paths, early exclusions, array-form scalar semantics, percent-bearing field names and Unique constraints alongside wildcard ignore IDs. The new cases reproduce skipped validation and formatting errors before the correction and pass afterward. Validation: formatting, full source/type/skeleton analysis, the Validation suite, Data validation support and Data ValidationTest pass. --- src/validation/src/Validator.php | 57 ++++++++--- .../ValidationCompiledExecutionTest.php | 97 +++++++++++++++++++ .../ValidationPreEvaluatedExclusionsTest.php | 22 +++++ 3 files changed, 164 insertions(+), 12 deletions(-) diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index cb297fcbd6..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); } diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index fc2917e57e..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']); 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( From 6c25a3d6c1d2e083f90be16584b8d48b9f7b94df Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:37:50 +0000 Subject: [PATCH 3/4] Preserve precision in conditional validation values String casts round floats before comparison, so conditional builders can skip a condition even when its input equals the supplied numeric value. AcceptedIf already retained finite precision through JSON encoding, but JSON failures turned non-finite values into false and could activate a condition against an empty input. Use one conditional-value formatter across the six builders. Keep the explicit null and boolean spellings, preserve finite float precision with JSON encoding, and retain literal non-finite representations when JSON cannot encode them. Keep positional parameters for keyed value arrays. Data's rule denormalizer now preserves float precision at its own shared conversion boundary as well. Tests cover matching computed floats and numeric strings, distinct values that previously matched after rounding, non-finite values, and precise Data attribute parameters. Existing upstream tests remain unchanged. Validation: all changed test files, the complete Validation suite, Data validation support and Data ValidationTest pass, along with formatting and full source/type/skeleton static analysis. --- .../Support/Validation/RuleDenormalizer.php | 5 +++ src/validation/src/Rules/AcceptedIf.php | 7 +-- .../src/Rules/Concerns/FormatsParameters.php | 17 +++++++ src/validation/src/Rules/DeclinedIf.php | 9 +--- src/validation/src/Rules/MissingIf.php | 9 +--- src/validation/src/Rules/MissingUnless.php | 9 +--- src/validation/src/Rules/PresentIf.php | 9 +--- src/validation/src/Rules/PresentUnless.php | 9 +--- .../Validation/RuleDenormalizerTest.php | 1 + .../ValidationRuleBuilderMethodTest.php | 44 +++++++++++++++++++ 10 files changed, 73 insertions(+), 46 deletions(-) diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index 76f9a2a7cc..3d27b81f3e 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 floats and can change which values match a conditional rule. + return json_encode($parameter) ?: (string) $parameter; + } + if (is_string($parameter) || is_numeric($parameter)) { return (string) $parameter; } diff --git a/src/validation/src/Rules/AcceptedIf.php b/src/validation/src/Rules/AcceptedIf.php index 1ba2701550..173f04697f 100644 --- a/src/validation/src/Rules/AcceptedIf.php +++ b/src/validation/src/Rules/AcceptedIf.php @@ -32,11 +32,6 @@ public function __construct(string $anotherField, string|int|float|bool|array|nu */ public function toArray(): array { - $values = array_map( - static fn (string|int|float|bool|null $value): string|false => is_string($value) ? $value : json_encode($value), - $this->values, - ); - - return ['accepted_if', $this->anotherField, ...array_values($values)]; + 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 index 60747a2421..600ba80573 100644 --- a/src/validation/src/Rules/Concerns/FormatsParameters.php +++ b/src/validation/src/Rules/Concerns/FormatsParameters.php @@ -17,6 +17,23 @@ public function __toString(): string 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; non-finite values retain their literal representation. + is_float($value) => json_encode($value) ?: (string) $value, + default => (string) $value, + }, array_values($values)); + } + /** * Format parameters for a legacy validation rule string. */ diff --git a/src/validation/src/Rules/DeclinedIf.php b/src/validation/src/Rules/DeclinedIf.php index acd845c5a6..c4472c4f47 100644 --- a/src/validation/src/Rules/DeclinedIf.php +++ b/src/validation/src/Rules/DeclinedIf.php @@ -29,13 +29,6 @@ public function __construct(string $anotherField, string|int|float|bool|array|nu */ public function toArray(): array { - $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { - is_null($value) => 'null', - $value === true => 'true', - $value === false => 'false', - default => (string) $value, - }, $this->values); - - return ['declined_if', $this->anotherField, ...array_values($values)]; + return ['declined_if', $this->anotherField, ...$this->formatConditionalValues($this->values)]; } } diff --git a/src/validation/src/Rules/MissingIf.php b/src/validation/src/Rules/MissingIf.php index 3697931e51..41509a7286 100644 --- a/src/validation/src/Rules/MissingIf.php +++ b/src/validation/src/Rules/MissingIf.php @@ -29,13 +29,6 @@ public function __construct(string $anotherField, string|int|float|bool|array|nu */ public function toArray(): array { - $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { - is_null($value) => 'null', - $value === true => 'true', - $value === false => 'false', - default => (string) $value, - }, $this->values); - - return ['missing_if', $this->anotherField, ...array_values($values)]; + 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 index acfdba41c0..709871d1f3 100644 --- a/src/validation/src/Rules/MissingUnless.php +++ b/src/validation/src/Rules/MissingUnless.php @@ -29,13 +29,6 @@ public function __construct(string $anotherField, string|int|float|bool|array|nu */ public function toArray(): array { - $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { - is_null($value) => 'null', - $value === true => 'true', - $value === false => 'false', - default => (string) $value, - }, $this->values); - - return ['missing_unless', $this->anotherField, ...array_values($values)]; + return ['missing_unless', $this->anotherField, ...$this->formatConditionalValues($this->values)]; } } diff --git a/src/validation/src/Rules/PresentIf.php b/src/validation/src/Rules/PresentIf.php index e7e3c41d88..9898c6ce52 100644 --- a/src/validation/src/Rules/PresentIf.php +++ b/src/validation/src/Rules/PresentIf.php @@ -29,13 +29,6 @@ public function __construct(string $anotherField, string|int|float|bool|array|nu */ public function toArray(): array { - $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { - is_null($value) => 'null', - $value === true => 'true', - $value === false => 'false', - default => (string) $value, - }, $this->values); - - return ['present_if', $this->anotherField, ...array_values($values)]; + 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 index ce32b7bea8..46d773d08a 100644 --- a/src/validation/src/Rules/PresentUnless.php +++ b/src/validation/src/Rules/PresentUnless.php @@ -29,13 +29,6 @@ public function __construct(string $anotherField, string|int|float|bool|array|nu */ public function toArray(): array { - $values = array_map(static fn (string|int|float|bool|null $value): string => match (true) { - is_null($value) => 'null', - $value === true => 'true', - $value === false => 'false', - default => (string) $value, - }, $this->values); - - return ['present_unless', $this->anotherField, ...array_values($values)]; + return ['present_unless', $this->anotherField, ...$this->formatConditionalValues($this->values)]; } } diff --git a/tests/Data/Support/Validation/RuleDenormalizerTest.php b/tests/Data/Support/Validation/RuleDenormalizerTest.php index bba63d1ff0..2d609305b7 100644 --- a/tests/Data/Support/Validation/RuleDenormalizerTest.php +++ b/tests/Data/Support/Validation/RuleDenormalizerTest.php @@ -65,6 +65,7 @@ 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 '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/Validation/ValidationRuleBuilderMethodTest.php b/tests/Validation/ValidationRuleBuilderMethodTest.php index 28403c4ee8..9c2d934051 100644 --- a/tests/Validation/ValidationRuleBuilderMethodTest.php +++ b/tests/Validation/ValidationRuleBuilderMethodTest.php @@ -277,6 +277,50 @@ public function testConditionalRulesPreserveScalarSemantics(): void } } + 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, INF] as $value) { + $rule = Rule::acceptedIf('other', $value); + + $this->assertSame(['other', (string) $value], 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. */ From 8e50d89e406959f7140c78af8e433ce5daceae0f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:10:16 +0000 Subject: [PATCH 4/4] Avoid NAN string-cast warnings during rule normalization PHP 8.5 warns when NAN is cast to a string. The non-finite fallback in conditional rule builders and Data parameters used that cast, and the regression test repeated it while constructing its expected value. Return the explicit NAN spelling before attempting JSON/string conversion. Keep finite precision and the existing infinity output unchanged. Use literal expected values for NAN and both infinities, and cover Data's NAN and infinity conversion through its existing parameter provider. Comments explain why both the explicit NAN case and the fallback are needed. Reproduced the two CI warnings in the PHP 8.5 CI image before the fix. Both affected test files now pass there without warnings; PHP 8.4 checks, targeted source analysis, formatting and diff checks also pass. --- src/data/src/Support/Validation/RuleDenormalizer.php | 4 ++-- src/validation/src/Rules/Concerns/FormatsParameters.php | 4 ++-- tests/Data/Support/Validation/RuleDenormalizerTest.php | 2 ++ tests/Validation/ValidationRuleBuilderMethodTest.php | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index 3d27b81f3e..48bfe90b23 100644 --- a/src/data/src/Support/Validation/RuleDenormalizer.php +++ b/src/data/src/Support/Validation/RuleDenormalizer.php @@ -120,8 +120,8 @@ protected function normalizeRuleParameter( } if (is_float($parameter)) { - // String casts round floats and can change which values match a conditional rule. - return json_encode($parameter) ?: (string) $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)) { diff --git a/src/validation/src/Rules/Concerns/FormatsParameters.php b/src/validation/src/Rules/Concerns/FormatsParameters.php index 600ba80573..e758cfbaa4 100644 --- a/src/validation/src/Rules/Concerns/FormatsParameters.php +++ b/src/validation/src/Rules/Concerns/FormatsParameters.php @@ -28,8 +28,8 @@ protected function formatConditionalValues(array $values): array is_null($value) => 'null', $value === true => 'true', $value === false => 'false', - // JSON preserves finite precision; non-finite values retain their literal representation. - is_float($value) => json_encode($value) ?: (string) $value, + // 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)); } diff --git a/tests/Data/Support/Validation/RuleDenormalizerTest.php b/tests/Data/Support/Validation/RuleDenormalizerTest.php index 2d609305b7..c4111c8cf9 100644 --- a/tests/Data/Support/Validation/RuleDenormalizerTest.php +++ b/tests/Data/Support/Validation/RuleDenormalizerTest.php @@ -66,6 +66,8 @@ public static function rules(): iterable 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/Validation/ValidationRuleBuilderMethodTest.php b/tests/Validation/ValidationRuleBuilderMethodTest.php index 9c2d934051..505839d269 100644 --- a/tests/Validation/ValidationRuleBuilderMethodTest.php +++ b/tests/Validation/ValidationRuleBuilderMethodTest.php @@ -308,10 +308,10 @@ public function testAcceptedIfNonFiniteValuesDoNotMatchEmptyOrFalseInput(): void { $translator = new Translator(new ArrayLoader, 'en'); - foreach ([NAN, INF] as $value) { + foreach ([[NAN, 'NAN'], [INF, 'INF'], [-INF, '-INF']] as [$value, $expected]) { $rule = Rule::acceptedIf('other', $value); - $this->assertSame(['other', (string) $value], ValidationRuleParser::parse($rule)[1]); + $this->assertSame(['other', $expected], ValidationRuleParser::parse($rule)[1]); foreach (['', false] as $other) { $this->assertTrue((new Validator($translator, ['other' => $other, 'terms' => false], [