diff --git a/AGENTS.md b/AGENTS.md index c4ea9ede47..090919a9ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ The Working rules and the Avoid overengineering rules apply to all work in this - **Use one source of truth** — Put all user documentation in `src/docs/`. Package READMEs are intentionally minimal, not a second documentation surface, and must not duplicate user documentation. - **Write user documentation in Laravel-docs prose** — Use the simple, direct, human-friendly style of first-party Laravel documentation. Prefer natural explanations and examples over implementation language; avoid internal jargon, stiff wording, and needless detail. -- **Keep the Laravel porting guide current and focused** — Whenever a framework change introduces, changes, or removes a public API, feature, configuration surface, or supported integration in a way that a Laravel application or package porter genuinely must account for, update `src/docs/porting-from-laravel.md` in the same change. Hard boot or runtime failures, silent semantic differences, and commonly used framework surfaces normally qualify. Internal implementation differences, performance work that preserves the public contract, incidental source drift, package-specific details, and narrow edge cases that do not change normal porting decisions do not. The guide is a high-signal starting context for humans and LLMs, not an exhaustive framework diff or dumping ground. Treat its context size as a design constraint: keep additions concise and action-oriented, link to the canonical feature documentation instead of duplicating its detail, and remove stale or duplicated guidance whenever editing the guide. +- **Keep the Laravel porting guide current and focused** — Whenever a framework change introduces, changes, or removes a public API, feature, behavior, configuration surface, or supported integration in a way that a Laravel application or package porter genuinely must account for, update `src/docs/porting-from-laravel.md` in the same change. Do not add entries for things like bug fixes, internal implementation differences, performance work that preserves the public contract, incidental source drift, or narrow edge cases unless they change what a porter needs to do. The guide is a high-signal starting context for humans and LLMs, not an exhaustive framework diff or dumping ground. Treat its context size as a design constraint: keep additions concise and action-oriented, link to the canonical feature documentation instead of duplicating its detail, and remove stale or duplicated guidance whenever editing the guide. #### Package READMEs diff --git a/docs/plans/2026-08-22-2137-components-validation-audit-remediation-plan-codex.md b/docs/plans/2026-08-22-2137-components-validation-audit-remediation-plan-codex.md new file mode 100644 index 0000000000..c10b89ecdd --- /dev/null +++ b/docs/plans/2026-08-22-2137-components-validation-audit-remediation-plan-codex.md @@ -0,0 +1,937 @@ +# Validation audit remediation plan + +Status: Complete + +Branch: `audit/validation-remediation` from `0.4` + +Scope: master-audit findings 15–20 plus validation defects exposed while reviewing their shared parsing, compilation, execution, and batching boundaries + +## Goal + +Fix the validation optimizer's correctness gaps without giving back the architecture's principal performance gains. Preserve Laravel's supported validation API and ordered behavior, while retaining Hypervel's O(n) wildcard expansion, worker-lifetime immutable plan cache, branch-free exact-base execution loop, inline predicates, exclusion prepass, and wildcard database-presence batching. Validator subclasses retain a small Laravel-shaped delegated loop so extension behavior never depends on base-class optimizer assumptions. Restore upstream-safe rule-object canonicalization so modern fluent rules benefit from the same compiled path instead of becoming a second, slower architecture. + +The finished code must be the simplest design that is correct under Hypervel's long-lived concurrent workers. It must add no external request/coroutine-context state, locks, worker-global mutable results, shadow validator, resumable executor, database-specific SQL, or duplicate maintenance registry of Laravel rule names. Execution-local facts may live only on the validator and the verifier already installed for one `passes()` call. + +## Scope and findings + +- **15 — eager presence batching violates rule order:** raw wildcard values are queried before preceding rules can reject them. This can send invalid values to typed PostgreSQL columns, execute SQL for excluded/absent attributes, and bypass ordinary `exists` / `unique` skip behavior. +- **16 — bytewise precomputed presence results do not model database equality:** a case-insensitive database match can be treated as absent, allowing a duplicate through `unique`. Array-valued `exists` additionally needs database `DISTINCT` semantics rather than a count of distinct PHP strings. +- **17 — exclusion pre-evaluation ignores rule order:** an `exclude_if` / `exclude_unless` later in the rule list currently erases failures from earlier rules. +- **18 — exclusion wildcard substitution mistakes literal numeric path segments for wildcard captures.** +- **19 — delegated comparison rules leak transient numeric-message state into later inline size failures.** +- **20 — compiled plans retain unused fields and a duplicated implicit-rule registry.** +- **Additional verified defect — `date_format` uses loose numeric-string comparison:** padded formats such as `m` accept unpadded strings such as `'1'`. Laravel 13.x shares the bug, but its documented contract says the value must match the selected PHP format. +- **Additional verified defect — `json` throws on resource input:** both Hypervel and Laravel call `method_exists()` with a resource. Hypervel also carries a duplicate inline implementation that can drift from the delegated predicate. +- **Additional verified defect — safe fluent rules miss canonicalization, caching, and inlining:** Hypervel's `ValidationRuleParser::prepareRule()` returns ordinary `Stringable` rule objects where Laravel returns their canonical string. Common modern forms such as `Rule::in()` therefore remain uncacheable and delegated, and Hypervel-only presence-object machinery compensates for a one-line upstream omission. +- **Additional verified defect — falsey database-rule values serialize incorrectly:** `Unique::ignore(0)` / `ignore('0')` become the no-ignore `NULL` sentinel, while `DatabaseRule::where(..., false)` and `whereNot(..., false)` become empty-string constraints that PostgreSQL rejects for typed columns. The current object-metadata batch path happens to preserve an ignored zero while ordinary validation does not, so deleting that workaround requires fixing the owning serializers in the same change. +- **Additional verified defect — common typeless size rules are needlessly delegated:** `max`, `min`, `size`, and `between` inline only when a sibling type rule selects one of four modes. Laravel's actual rule is simpler: only numeric semantics come from sibling rules; arrays, files, and strings are selected from the runtime value. The duplicate compiler mapping also omits `decimal`. +- **Additional verified defect — compiled stop checks reparse rules after failures:** the plan already owns `bail`, and uploaded/implicit failures already exist in `failedRules`, but the compiled loop calls `shouldStopValidating()` and rescans the attribute's rules after every failure. +- **Additional verified defect — global early-stop can be preempted by speculative presence SQL:** `stopOnFirstFailure()` can make ordinary execution return after an earlier failure without reaching a later presence rule, while eager batching submits that later value first. A PostgreSQL type error then replaces the clean validation failure and aborts any surrounding transaction. +- **Additional verified defect — presence facts erase PDO binding identity:** string `'1'` and integer `1` currently collapse to one candidate and fact key even though the connection binds them as `PDO::PARAM_STR` and `PDO::PARAM_INT`. PostgreSQL can hide the integer binding error when the string wins deduplication. MySQL silently gives both candidates the string result or both the integer result against stored `'01'`, producing order-dependent false failures and false passes. +- **Implementation-review defect — date/time candidates bypass grammar-owned binding conversion:** batching string-casts `Stringable` date objects, while ordinary validation formats every `DateTimeInterface` through `Connection::prepareBindings()`. The same value can therefore query with different strings and silently produce different results. +- **Implementation-review defect — resolved leading exclusions block their own presence batch:** the exclusion prepass proves a first-position exclusion is non-excluding, but the prefix walk still treats that delegated check as uncertain and turns a common wildcard form back into one query per item. +- **Post-implementation audit defect — validator subclasses lose Laravel extension hooks:** all-delegated plans discard `nullable`, `bail`, and `sometimes` as executable rules, apply the base-class `sometimes` shortcut, and bypass overridden `shouldStopValidating()` methods. +- **Post-implementation audit defect — escaped-dot attributes cross internal and public key domains:** prior presence failures are queried with placeholder-containing keys while exclusions are recorded with cleaned keys, causing extra presence queries, later-rule execution, and retained excluded data. +- **Post-implementation audit defect — presence batching invokes arbitrary `Stringable` code early:** candidate and condition normalization executes user code during preflight and again at runtime rather than preserving ordinary rule order. +- **Post-implementation audit defect — non-scalar array-tuple parameters enter compilation:** compiler context and inline checks can invoke user objects before bail or exclusion would reach the rule. +- **Post-implementation audit defect — exclusion hints are applied outside Laravel attribute order:** future parent exclusions suppress earlier descendants, global early-stop still applies later hints, stale original-data outcomes survive descendant removal, and execution-time parent exclusions fail to skip later descendants. +- **Post-implementation audit improvement — validators without exclusions scan every plan for mutators:** the mutation gate is useful only after an exclusion is found and should be resolved lazily. +- **Post-implementation audit performance defect — active exclusions remain quadratic:** Laravel's protected exclusion list is scanned at every attribute/check boundary and deduplicated after every activation. Large wildcard exclusion sets therefore retain O(e²) work after the ordered prepass. +- **Final-review defect — absent `sometimes` attributes skip later exclusions:** the exact-base loop's plan-level shortcut bypasses exclusions even though Laravel deliberately runs them before its optional-presence gate, allowing descendant rules to execute after their absent parent should have been excluded. +- **Final-review performance defect — non-presence wildcards pay presence-planner work:** the planner reads values and walks exclusion sets for every expanded wildcard attribute before discovering that its plan has no `exists` / `unique` check. + +## Research and settled decisions + +### Hypervel architecture remains the right base + +The defects are optimizer-boundary mistakes, not flaws in the overall refactor. Current source has the intended shape: + +1. `ValidationRuleParser` performs O(n) wildcard expansion and normalizes pipe and array syntax into ordered rule arrays. +2. `RuleCompiler` emits immutable `AttributePlan` instances containing inline or delegated checks. +3. `RulePlanCache` shares those plans between attributes and requests for the worker lifetime. +4. `PlanExecutor` owns the branch-free exact-base loop and a small Laravel-shaped subclass loop; every delegated rule still calls the established `validateAttribute()` path. +5. Exclusion and database batching are pre-execution optimizations guarded to the exact base `Validator` with no mutating extension surface. + +Baseline focused and database-batching integration tests are green. Previous representative benchmarks found material wins for nested and conditional validation and a smaller but real inline-execution win. The implementation must preserve those gains. + +### Laravel API and reference behavior + +Local references were checked at: + +- `examples/laravel/framework`, branch `13.x`, commit `bd71b45fbb7e`; +- `examples/laravel/docs`, branch `13.x`, commit `8939b76399f8`. + +Relevant conclusions: + +- Laravel's current documentation presents rule arrays as the preferred form, but the framework still explicitly accepts and centrally parses string rules with `explode('|', $rule)`. The string form is not deprecated. Hypervel must keep both forms; neither finding is caused by pipe syntax because both forms have already become the same ordered rule array before compilation. +- Laravel calls `validateAttribute()` for every declared subclass rule, checks active exclusions at each attribute entry, and dynamically calls protected `shouldStopValidating()` after each rule. Hypervel may optimize the exact base class, but its subclass path must retain that extension behavior. +- Laravel validates rules in declaration order and skips `Exists` / `Unique` after any prior failure on the attribute. The batch planner must preserve that behavior rather than eagerly submitting every raw value. +- Laravel's `ValidationRuleParser::prepareRule()` preserves closures, `RuleContract` instances, callback-bearing `Exists` / `Unique`, and `CompilableRules`, then stringifies every other object. Hypervel is missing only the final `(string)` cast. Restoring it is the generic parity fix: it evaluates conditional fluent rules once at parse time, makes pure fluent rules cacheable, and avoids a brittle class allowlist. +- Callback-free `Exists` / `Unique` strings contain all metadata used by ordinary validation and batching. Callback-bearing objects remain objects and delegated. Hypervel's internal `DatabasePresenceRule`, `presenceMetadata()`, and special compiler/planner branches become dead after upstream canonicalization and should be removed. +- Laravel's fluent `Unique` serializer also shares a truthiness bug: zero-valued ignored IDs become `NULL`. The matching database-rule serializer loses `false` conditions as an empty string. `null` is the only no-ignore sentinel; normalize boolean where values to integers before formatting, matching ordinary query-builder binding across supported drivers. +- Laravel's `getSize()` asks one semantic question of the sibling rules: whether numeric semantics are active. It then dispatches on the actual value for array count, file kilobytes, or string length. Hypervel's four-way `SizeMode` duplicates rule categories, fails to inline common `required|max:255`, and misses `Decimal` despite `Validator::$defaultNumericRules` already being the authority. +- Laravel uses `getExplicitKeys()` and `replaceAsterisksInParameters()` for dependent wildcard fields. Hypervel should reuse that authority. +- Laravel's database `getMultiCount()` is `distinct()->count($column)`. PHP bytewise uniqueness is not an equivalent substitute under collations or database coercion. +- Laravel 13.x still uses loose comparison in `validateDateFormat()`. This is an upstream bug: PHP has separate padded and unpadded format tokens, and the docs say the value must match the requested format. Hypervel will fix the shared boundary and can offer the change upstream separately. +- Laravel 13.x shares the resource-unsafe `validateJson()` predicate. PHP 8 exposes `Stringable` for the supported object boundary, while resources are neither scalar nor `Stringable`; fix that predicate rather than guarding an optimizer around it. +- Laravel parses safe fluent conditions once while exploding rules. Hypervel must restore that timing. The compiler still has a context pre-scan and emission pass, so one compile-local list of parsed pairs should feed both passes rather than parsing tokens twice on every cache miss. + +No Swoole defect was exposed by this investigation. + +### Optimizer invariants + +These invariants govern every change in this slice: + +- Actual validation, messages, exclusion, bail, and stop behavior remain owned by the existing execution loop. Preflight is a side-effect-free predicate pass only. +- A batch may omit a concrete value only when shared execution gates or safely repeated preceding checks prove that its presence rule cannot execute. +- A value with any unsupported or unsafe preceding check is **uncertain**, not failed. It is not submitted eagerly; if normal execution later reaches presence validation, the execution-local verifier delegates that probe to the original verifier. +- One uncertain value must not disable batching for safe siblings. Declining an entire group would let one unusual value turn 999 safe values back into 1,000 queries. +- Preflight writes nothing to `AttributePlan`. The same plan instance can be shared by multiple wildcard attributes and concurrent requests. +- Inline preflight fails closed. A positive `CheckType` allowlist with `default => false` means a future inline rule is correct by default and merely forgoes batching until explicitly reviewed. +- The allowlist is safety metadata, not a second rule registry. An exhaustive test must partition every `CheckType` into reviewed-safe or reviewed-unsafe cases so adding an enum case forces an explicit decision. +- Exclusion pre-evaluation is disabled only by mutation-capable behavior actually used by the compiled plans, not by an unused registered extension or a wrapper interface that never reaches its wrapped rule. +- Presence facts are keyed by database query shape and PDO binding identity, not by attribute. Request-data mutation cannot stale them: a new binding identity is unknown and delegates; an identity queried elsewhere retains a database-proven fact. Presence batching therefore does not share the exclusion prepass's data-mutation gate. +- Query-shape identity is enforced by the precomputed verifier itself, not by a separate table/column collision census. A runtime probe can consume facts only when its connection, table, column, scalar where conditions, and effective unique exclusion exactly match the grouped query. +- Validation predicates may read the database, but must not write database state and depend on later presence-query ordering. Detecting arbitrary database side effects in user code is impossible, and globally disabling batching for every custom rule would destroy the optimization without providing a coherent guarantee. +- The precomputed verifier stores only facts a database query proved. Anything unqueried or ambiguous delegates. +- Presence batching and fact keys accept only native strings, integers, and floats. Objects and other values capable of user code or connection-owned conversion remain on the ordinary verifier path. +- Compiler-owned inline checks contain scalar parameters only. Any attribute containing a rule with a non-scalar parsed parameter uses the all-delegated path; correctness therefore does not depend on a second per-rule parameter-safety registry. +- The exact base validator owns the optimized execution loop. Validator subclasses use a separate Laravel-shaped loop over delegated checks so protected and public extension hooks cannot be bypassed by base-class shortcuts. +- Pre-evaluated exclusions are ordered hints, not globally active state. A resolved exclusion becomes active only when execution reaches its exact attribute, and only exclusions actually reached by execution affect final data removal. +- All verifier facts and fallback memoization live only for the current `passes()` execution. No `CoroutineContext`, static map, or worker cache is permitted. +- `executeCompiledPlans()` iterates the compiled-plan array captured at the start of the call. A rule may mutate validation data, but it cannot introduce an uncatalogued query shape into that execution; rules added during execution take effect only on a later `passes()` compilation. + +## Implementation plan + +### 1. Restore Laravel's generic rule-object canonicalization + +Files: + +- `src/validation/src/ValidationRuleParser.php` +- `src/validation/src/Contracts/DatabasePresenceRule.php` +- `src/validation/src/Rules/DatabaseRule.php` +- `src/validation/src/Rules/Exists.php` +- `src/validation/src/Rules/Unique.php` +- `src/validation/src/DelegatedCheck.php` +- `src/validation/src/RuleCompiler.php` +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationRuleParserTest.php` +- `tests/Validation/ValidationRulePlanCacheTest.php` +- `tests/Validation/ValidationRuleCompilerTest.php` +- presence tests named below + +Restore the one missing upstream line at the end of `ValidationRuleParser::prepareRule()`: + +```php +return (string) $rule; +``` + +Keep the preceding upstream guards exactly as the semantic boundary: + +- non-objects and `RuleContract` instances remain unchanged; +- closures and modern `ValidationRule` / `InvokableRule` objects remain wrapped rule contracts; +- callback-bearing `Exists` / `Unique` remain objects so their query callbacks survive; +- `CompilableRules` still compile against the current attribute and data; +- every other object is a pure Laravel-style string rule and is canonicalized once during rule explosion. + +Do not replace this with an `In` / `NotIn` / `Dimensions` / presence-rule class list. The generic upstream boundary already handles future stringable rules without another registry. It also means `Rule::requiredIf()`, `Rule::excludeIf()`, and `Rule::excludeUnless()` evaluate their closure once during parsing, exactly when Laravel does, rather than entering the compiler as stateful objects. + +After canonicalization, remove the compensating Hypervel-only presence-object layer: + +- delete the internal `Contracts\DatabasePresenceRule` interface; +- remove `implements DatabasePresenceRule` and its imports from `Exists` / `Unique`; +- delete `DatabaseRule::presenceMetadata()` and `Unique::presenceMetadata()`; +- delete the special `Exists` / `Unique` branches in `RuleCompiler`; callback-bearing objects can use the ordinary non-string delegated branch while retaining the object in `originalRule`; +- delete `DelegatedCheck::$ruleObject`; `originalRule` already carries the same custom `RuleContract` object used by execution and mutation analysis, so retaining both references has no consumer; +- make presence metadata extraction consume each `DelegatedCheck`'s parsed `ruleName` / `parameters`. Use `originalRule` only to reject callback-bearing `Exists` / `Unique`; do not stringify or parse the same check again; +- delete `extractObjectPresenceRuleMeta()` and all `presenceMetadata()` branches after confirming no caller remains. + +Fix the database-rule serializers at their owning boundary before removing that layer: + +- serialize an ignored ID whenever it is non-null, so integer zero, string zero, and float zero become `"0"` while `null` remains the no-ignore `NULL` sentinel; +- normalize boolean values to integers in one small helper used by both `DatabaseRule::where()` and `whereNot()`, so `false` serializes as `"0"` / `"!0"` rather than an empty string. Keep `enum_value()` normalization in that helper and add no extra sentinels or guards for unsupported array/object key values. + +Callback-free `Rule::exists()` / `Rule::unique()` now follow the same string path as Laravel, including inferred columns. Callback-bearing forms remain uncacheable, are rejected from batching via `queryCallbacks()`, and execute through the ordinary verifier with their original object as `currentRule`. This preserves the public fluent API while deleting internal machinery. + +Tests: + +- safe fluent `In`, `NotIn`, `Dimensions`, `Exists`, and `Unique` objects appear in `getRules()` as the same canonical strings Laravel produces; +- `Rule::in()` / `Rule::notIn()` compile to inline checks and attributes containing them hit `RulePlanCache` on later validators; +- callback-free presence objects are cacheable and batchable, including inferred-column behavior matching the ordinary string path; +- callback-bearing presence objects remain objects, retain callbacks, never enter a batch, and produce no runtime lookup key so they always delegate; +- integer zero, string zero, and float zero ignored IDs canonicalize as `"0"`, reach `getCount()` as a non-null exclusion, and work through ordinary, fallback, and batched wildcard paths; `ignore(null)` and an unsaved model's null key retain the `NULL` sentinel; +- boolean `where()` / `whereNot()` constraints canonicalize as `"0"` / `"!0"` and validate against typed columns without changing true or non-boolean values; +- `RuleContract`, `ValidationRule`, `InvokableRule`, `CompilableRules`, and closure rules retain their established object/wrapper behavior and remain uncacheable; +- conditional fluent-rule closures execute once during parser explosion and their one result determines the exploded rule; do not add a compiler-only double-evaluation test for a stateful object that no longer reaches the compiler. + +Pin the ignored-zero behavior end to end with a mocked verifier assertion and one shared real-database case inherited by all four driver wrappers. Treat both serializer defects as upstream Laravel issue/PR candidates after the Hypervel fix; upstream coordination is not part of this implementation. + +### 2. Share the non-implicit execution gates + +Files: + +- `src/validation/src/PlanExecutor.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` + +Extract two small protected predicates used by both normal inline execution and batch preflight: + +```php +protected function shouldSkipNonImplicitCheck( + AttributePlan $plan, + mixed $value, + bool $exists, +): bool { + return ! $exists + || (is_string($value) && trim($value) === '') + || ($plan->nullable && $value === null); +} + +protected function shouldFailInvalidUpload(string $attribute, mixed $value): bool +{ + return $value instanceof UploadedFile + && ! $value->isValid() + && $this->hasRule($attribute, array_merge($this->fileRules, $this->implicitRules)); +} +``` + +Keep `addFailure($attribute, 'uploaded', [])` solely in the real executor. Preflight may use the exact predicate to prove that presence cannot run, but it must never create a message. + +The file/implicit-rule condition is load-bearing. An invalid upload with only an `exists` rule does not take the uploaded-file failure branch and must remain capable of reaching the real presence verifier. + +Replace the duplicated inline gates with these predicates and remove comments that describe the old inline-only implementation. + +Tests: + +- absent, trimmed-empty, and nullable-null values skip non-implicit inline and presence checks; +- an invalid upload with a file or implicit rule produces the existing `uploaded` failure and no database probe; +- an invalid upload without either condition is not falsely classified as a proven failure. + +### 3. Add a conservative inline-preflight boundary + +Files: + +- `src/validation/src/PlanExecutor.php` +- `src/validation/src/DelegatedCheck.php` +- `src/validation/src/RuleCompiler.php` +- `src/validation/src/Concerns/ValidatesAttributes.php` +- `src/validation/src/Enums/CheckType.php` +- `tests/Validation/ValidationPlanExecutorTest.php` +- `tests/Validation/ValidationRuleCompilerTest.php` +- `tests/Validation/ValidationValidatorTest.php` + +Compiler-owned inline checks must contain scalar parameters only. After parsing all rules in `RuleCompiler::compile()`, inspect every parsed parameter list; if any parameter is non-scalar, return `compileAllDelegated($rules)` for that whole attribute. Explicit array tuples with objects, nested arrays, resources, or null are already uncacheable, and delegating their siblings preserves Laravel timing without adding a third date-format context state or a per-rule object allowlist. String rules and canonicalized fluent rules parse to scalar strings and retain their optimized path. + +Delegation preserves the ordinary parameter contract; it does not make every position coercible. Hypervel keeps table, column, ID-column, and where-key identifiers string-typed so invalid identifiers fail natively. Supported value positions, such as database where values, retain their ordinary one-time conversion only if ordered execution reaches them. + +Derive one readonly `DelegatedCheck::$parametersAreScalar` boolean in its constructor. This is immutable rule metadata, not execution state: exclusion and presence optimizers use it to reject a delegated check before serializing, casting, or otherwise inspecting non-scalar parameters. Computing it once avoids rescanning cached presence-rule parameters for every wildcard candidate and cannot drift from the check's actual parameter array. + +Remove `Stringable` parameter casting from the `In` / `NotIn` compiler arms. The attribute-level boundary makes scalar parameters an invariant before `tryInline()` runs, so preflight does not need another parameter-safety check. + +Place `canPreflightInline(InlineCheck $check, mixed $value): bool` immediately beside `executeInline()` so the safety classification and implementation are reviewed together. + +Reject all object and resource values first. Objects can invoke user magic methods, `Countable::count()`, overridable file methods, and configurable object behavior. Resources are not legitimate presence candidates. Do **not** reject arrays: array-valued `exists` is supported, and native array/type/size predicates are safe. + +Repair JSON validation at its actual shared boundary before relying on that classification, replacing both existing guards with one exhaustive type boundary: + +```php +if (! is_scalar($value) && ! $value instanceof Stringable) { + return false; +} +``` + +Null and arrays are neither scalar nor `Stringable`, so no preceding special case remains. PHP 8 automatically implements `Stringable` for classes declaring `__toString()`, while the check is safely false for a resource. Change the `Json` inline arm to call `$this->validateJson($attribute, $value)`, then delete the byte-identical `executeInlineJson()` helper and its unused `Json` import. This direct call has no parameter parsing, rule lookup, dispatch, or state overhead because `validateJson()` does not use the attribute. Do not generalize the pattern to inline rules whose delegated methods do more work. + +Use a positive match with `default => false`. The 41 currently safe cases are: + +```text +TypeString, TypeNumeric, TypeInteger, TypeIntegerStrict, TypeBoolean, TypeArray, +Email, Url, Ip, Ipv4, Ipv6, Uuid, Ulid, Json, Ascii, HexColor, MacAddress, +Alpha, AlphaAscii, AlphaDash, AlphaDashAscii, AlphaNum, AlphaNumAscii, +Lowercase, Uppercase, +SizeMin, SizeMax, SizeBetween, SizeExact, +Digits, DigitsBetween, MinDigits, MaxDigits, +StartsWith, EndsWith, DoesntStartWith, DoesntEndWith, +In, NotIn, IsDate, DateFormat +``` + +The size cases are safe only when they cannot reach user code or a reachable Brick Math exception: + +```php +if ($check->param['numeric'] && is_numeric($value)) { + return ! Str::contains((string) $value, 'e', ignoreCase: true) + && (! is_float($value) || is_finite($value)); +} +``` + +Exponent values can invoke the configurable exponent-range callback. Non-finite floats pass PHP's `is_numeric()` but Brick Math rejects `INF` / `NAN`; treating them as unsafe preserves normal rule order if execution would throw. Objects are already rejected, so preflight also avoids file stat calls and magic string casts. Arrays continue through native `count()`. + +Leave these eight cases unlisted: + +- `Regex`, `NotRegex`: malformed patterns can emit warnings; +- `MultipleOf`: Brick Math throws for values such as `INF` and `NAN`; +- `DateAfter`, `DateBefore`, `DateAfterOrEq`, `DateBeforeOrEq`, `DateEquals`: they can reach the configurable `DateFactory` callback. + +`IsDate` and `DateFormat` are safe scalar/native predicates and do not use the `Date` facade. Bare `Email` is also allowed after the object guard; Hypervel auto-singletons the stateless Egulias validator, and a hypothetical stateful concrete rebinding is not a supported behavior worth turning common `email|exists` lists into N queries. + +Update both maintenance docblocks to state the four review points for a new inline rule: `CheckType`, `RuleCompiler::tryInline()`, `executeInline()`, and the explicit preflight-safety decision. An inline case may enter the preflight allowlist only after proving repeat evaluation is free of user callbacks, I/O, warnings, and reachable exceptions; omission is correctness-safe and only disables batching across that prefix. Do not add behavior or another rule-name registry to the enum. + +Tests: + +- define the reviewed-safe and reviewed-unsafe case lists in the test, assert their union equals `CheckType::cases()` with no duplicates, and exercise the object/resource guard, array support, exponent callback, and non-finite-size exceptions; +- prove a Stringable object is not cast during preflight; +- prove a non-scalar parameter anywhere in an attribute selects the all-delegated plan, executes user code only if ordered validation reaches it, and does not affect scalar string, array-tuple, or fluent forms; +- prove exponent callbacks and file methods execute only once and in normal order; +- prove resource-valued `json` fails rather than throwing in both base inline and all-delegated execution, while valid scalar and `Stringable` JSON retain their behavior; +- keep `required|integer|min:1|exists` batchable for ordinary scalar values; +- keep `required|array|exists` batchable. + +### 4. Replace four-way size modes with canonical numeric semantics + +Files: + +- `src/validation/src/Enums/SizeMode.php` +- `src/validation/src/AttributePlan.php` +- `src/validation/src/RuleCompiler.php` +- `src/validation/src/PlanExecutor.php` +- `src/validation/src/Concerns/ValidatesAttributes.php` +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationRuleCompilerTest.php` +- `tests/Validation/ValidationPlanExecutorTest.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` + +Delete `SizeMode`. It encodes four compile-time modes that Laravel does not have and cannot determine the runtime value's actual shape. Compile each valid `min`, `max`, `size`, or `between` rule with one boolean: whether a sibling rule activates numeric semantics. The emitted check retains the original message parameters and its raw numeric threshold strings for precision. + +There must be one authority for numeric-size rules. Pass the exact base `Validator`'s existing `$defaultNumericRules` into `RuleCompiler::compile()` and let `collectContext()` derive the boolean with a strict membership check. Do not hard-code `Numeric`, `Integer`, and `Decimal` in the compiler, move a duplicate list into the enum, or infer the category from class names. `compileAllDelegated()` needs no numeric context. Because only the exact base class is cached and its default list is stable, the existing rule-array cache key remains sufficient. + +Use value-first size dispatch matching `getSize()`: + +```php +protected function sizeOf(string $attribute, mixed $value, bool $numeric): float|int|string +{ + if ($numeric && is_numeric($value)) { + return $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value)); + } + + if (is_array($value)) { + return count($value); + } + + if ($value instanceof SplFileInfo) { + return $value->getSize() / 1024; + } + + return mb_strlen((string) $value); +} +``` + +Make `getSize()` delegate to this one body with `$this->hasRule($attribute, $this->numericRules)` as the boolean, then delete `sizeOfWithExponentCheck()`. Both inline and delegated execution therefore share value dispatch and exponent enforcement instead of maintaining parallel implementations. + +Preserve precision-safe Brick Math comparison for numeric and file sizes. Classify every threshold at compile time by storing its `FILTER_VALIDATE_INT` result (or `null`) beside its raw string in the immutable `InlineCheck`. At execution, use the parsed integer only when the runtime size resolves to an array count or string length; otherwise use the raw threshold with Brick Math. Execution therefore performs only type/null checks. Decimal, exponent, and out-of-range thresholds are never rounded through `(float)`. Common `max:255` remains on the fast path without per-value parsing or a precision divergence. Do not choose value behavior solely from the sibling rule: `min:1|numeric` with `'abc'` must measure string length before the later `numeric` failure, just as Laravel does. + +With this representation, typeless rules such as `required|max:255` inline correctly and keep a following presence rule batchable. `Decimal` automatically activates numeric semantics because it already belongs to `$defaultNumericRules`; future changes to that canonical list cannot silently drift from compilation. + +Tests: + +- typeless `min`, `max`, `size`, and `between` inline and use runtime string, array, and file semantics with the same messages as delegated Laravel behavior; +- `numeric`, `integer`, and `decimal` siblings activate numeric semantics, including when the size rule appears first; +- mixed/contradictory type siblings follow Laravel's `hasRule($numericRules)` behavior rather than delegating; +- non-numeric values with numeric siblings fall through to runtime value shape and preserve rule order; +- exponent callbacks, precision-sensitive decimal thresholds, file kilobytes, invalid uploads, and non-finite numeric values retain delegated behavior; +- string/array size comparisons with decimal or integer-overflow thresholds remain exact while ordinary integer thresholds use native comparison; +- threshold integer classification happens once at compilation, not per checked value; +- delegated `getSize()` and inline comparisons share `sizeOf()` as the only value-dispatch/exponent implementation; +- no `SizeMode`, four-way mapping, or duplicated numeric-rule list remains. + +### 5. Build presence candidates from active compiled plans in order + +Files: + +- `src/validation/src/Validator.php` +- `src/validation/src/Concerns/ValidatesAttributes.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` +- validation database integration tests described in step 11 + +Rewrite the candidate half of `maybeBatchDatabaseChecks()` around the already filtered `compiledPlans`, not raw `$rules`: + +1. Retain the current wildcard-only optimization boundary. +2. Find the first compiled `Exists` / `Unique` check before reading the attribute value or applying exclusion gates, and skip plans with no presence check. Continue scanning from that index after the shared gates; do not add a cached plan flag, registry, or per-plan allocation. +3. Skip plans whose `sometimes` flag is set when the concrete key is absent. +4. Apply the shared non-implicit and invalid-upload predicates once per attribute, before the check loop; neither depends on the current check or index. +5. Locate each `Exists` / `Unique` `DelegatedCheck` by its already parsed rule name. Walk only its preceding checks, in declaration order, before resolving presence metadata: + - an `InlineCheck` may be evaluated only when `canPreflightInline()` returns true; + - ordinary `Required` may call `validateRequired()` only for non-object values; + - another `DelegatedCheck` makes this concrete value uncertain; + - a safely evaluated false result proves failure and omits the value; + - reaching the presence check after all safe passes makes the value batchable. + Use an indexed walk over the plan's list rather than allocating an `array_slice()` for every candidate. +6. Only after the prefix passes, extract metadata from the check's parsed name/parameters. Inspect `originalRule` only to reject callback-bearing presence objects. Retain the existing rejection when a unique rule's raw ignore parameter contains `[` or `*`: a wildcard field reference resolves to a different ignored value for each concrete item and would turn batching into one grouped query per item plus planning overhead. Do not add a special case for the rare non-wildcard field-reference form; one simple conservative guard is easier to maintain and ordinary validation already handles both forms correctly. + Return unknown before any metadata work when `DelegatedCheck::$parametersAreScalar` is false. Array-tuple non-scalar parameters must reach ordinary validation untouched: string-typed identifier positions still fail natively, while supported value positions retain their ordinary conversion. Planning must not invoke `__serialize()`, `__toString()`, or other conversion behavior. +7. Memoize only `parseTable()` results in a validator-owned map keyed by the raw table parameter and reset at the start of every `passes()` call. Make `parseTable()` the one authority so planning and real presence execution share the same model resolution; do not thread a by-reference planner accumulator. Model-class table resolution is stable within one validation execution, and developer-authored rule strings naturally bound the map. Do not memoize full metadata because inferred columns can depend on the concrete attribute. +8. Add no value for an uncertain candidate. If all candidates are uncertain, no batch query or verifier swap occurs. A lookup installed for safe siblings remains correct because unknown values delegate and runtime query-shape keys prevent a different presence query from consuming its facts. + +Conceptually there are three outcomes, but do not introduce an enum, result object, plan cursor, phased executor, or mutable plan field. A small private helper/local state is enough: + +```text +proven failure or shared skip -> no group value; presence cannot run +fully safe prefix -> group and submit value +uncertain prefix -> do not submit; runtime fallback if reached +``` + +Critical examples: + +- `multiple_of:5|exists` with `'abc'`: normal validation fails `multiple_of` and performs no SQL. Because `MultipleOf` is unsafe to preflight, the candidate is uncertain and must not be eagerly submitted. +- `min:1|integer|exists` with `'abc'`: numeric semantics are compiled from the sibling `integer`, but value-first size dispatch treats `'abc'` as length 3, so `min` passes and `integer` fails. Stopping at an unsafe size rule and submitting the raw value would be wrong. +- one exponent-form, file, or custom-prefix value must not disable batching for safe siblings. + +Building from `compiledPlans` automatically excludes plans removed by exclusion pre-evaluation. The plan-level `sometimes` gate prevents absent attributes from contributing values. Build every group with `PrecomputedPresenceVerifier::lookupKey()` from step 7 rather than retaining `buildPresenceGroupKey()`. Do not rebuild the current table/column collision census: query-shape-keyed lookups make different where, ignore, connection, and column shapes independent, while an unregistered or unknown runtime shape delegates. This also lets an ordinary non-wildcard presence rule share proven facts with an identical wildcard group without turning the wildcard batch back into N queries. + +Parent exclusions need an additional conservative boundary. A child presence rule can otherwise be queried before a parent plan excludes the subtree; on a typed PostgreSQL column, a bare invalid child value can throw even though normal execution never reaches it. During exclusion analysis, return one execution-local set of attributes whose exclusion outcome could not be safely resolved. Do not batch a strict descendant of one of those prefixes; same-attribute order is already handled by the check-prefix walk. Execution order need not be stored: if a parent normally runs after its child, declining the child's batch is only conservative, whereas an order map adds state for a rare optimization. Reuse the existing descendant-prefix walk, do not add state to cached plans, and do not globally disable unrelated groups. A safely resolved non-excluding parent adds no prefix, and a pre-excluded parent has already been removed. + +`stopOnFirstFailure` disables presence batching entirely under step 6. Global early-stop is the only cross-attribute execution break; a speculative PostgreSQL statement error can otherwise replace an earlier clean validation failure and abort the caller's transaction. Do not catch `QueryException`: catching it in PHP cannot repair PostgreSQL's aborted transaction state. Phased execution, savepoints, or schema/type probing would add disproportionate machinery, so validators that request global early-stop use the ordinary verifier while exclusion pre-evaluation remains enabled. + +Tests: + +- all-existing 1,000 `required|integer|exists` values issue one query; 1,001 issue two chunks; +- mixed valid/invalid integers submit only valid values, preserve ordered messages, and keep safe siblings batched; +- PostgreSQL `integer|exists` and `date|exists` / `date_format:Y-m-d|exists` reject invalid typed values without `QueryException` or presence SQL; +- a preceding safe failure, `bail`, nullable, empty, absent, `sometimes`, and pre-excluded attributes issue no inappropriate query; +- a child below any unresolved parent exclusion is not submitted early, while unrelated wildcard groups remain batchable; +- a preceding custom/delegated rule makes only that concrete value uncertain; +- a `Stringable` array-tuple where value is not converted during planning, then is converted exactly once by the ordinary verifier when ordered execution reaches it; +- an uncertain prefix that later fails performs no fallback; one that reaches presence performs one fallback; +- an all-uncertain group performs no batch query; +- different query shapes on the same table/column remain independent and correct rather than disabling one another; +- a non-wildcard presence rule with the same query shape does not disable wildcard batching and delegates only when its value is unknown; +- a data mutator that activates a previously skipped presence rule cannot consume facts from another query shape; +- two attributes sharing one cached `AttributePlan` can make different candidate decisions without cross-request/attribute state; +- string and array-tuple forms plus canonicalized callback-free presence objects retain their metadata/messages; callback-bearing objects remain delegated. + +### 6. Split optimizer gates at the real mutation boundary + +Files: + +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` + +Use separate gates for the two optimizations: + +```text +presence batching exact base Validator + exact DatabasePresenceVerifier + not stopOnFirstFailure +exclusion prepass exact base Validator + no used data-mutating extension/rule +``` + +Keep the early-stop condition on the inner presence-verifier gate. Exclusion pre-evaluation is a pure data pass that cannot produce a failure and remains safe and useful under `stopOnFirstFailure`. Add a concise source comment at that gate explaining that a failed speculative PostgreSQL query aborts the caller's transaction even when PHP catches the exception; do not add an exception catch, savepoint, transaction-state check, or deferred-query executor. + +Do not retain the current `$this->extensions === []` gate. Extension registration normally happens at application boot and an unused registered extension cannot affect this validator. For exclusion pre-evaluation, scan compiled delegated checks and block only when dispatch would actually reach a custom extension (the normalized name exists in `$extensions` and no concrete `validate*()` method handles it), is a `ClosureValidationRule`, or carries an actual `ValidatorAwareRule`. + +Unwrap `InvokableValidationRule` before classifying it: + +```php +if ($check->originalRule instanceof InvokableValidationRule) { + if ($check->originalRule->invokable() instanceof ValidatorAwareRule) { + return true; + } + + continue; +} +``` + +The wrapper always implements `ValidatorAwareRule`, but forwards the validator only when the inner rule implements it. A normal modern `ValidationRule` / `InvokableRule` receives data by value at most and cannot mutate this validator. It must not disable exclusion pre-evaluation. + +Keep `ClosureValidationRule` as an exclusion blocker because it passes the live validator as the fourth callback argument. Keep a direct or wrapped `ValidatorAwareRule` as a blocker. Regardless of these global exclusion decisions, a custom rule preceding presence remains locally uncertain under step 5. + +Presence batching must not use this data-mutation gate. Its maps store facts about a query shape and concrete value. If an earlier custom rule changes a value to one never submitted, runtime lookup is unknown and delegates. If it changes to a value submitted by another attribute, the fact remains database truth. Callback-bearing presence shapes remain unbatchable and their null lookup key always delegates. + +The unresolved-parent exclusion boundary in step 5 still applies when the exclusion prepass is disabled: data mutation can make an exclusion outcome unknowable, so descendants of that potential exclusion are not submitted. This preserves typed-database safety without forfeiting unrelated groups. + +Tests: + +- an unused registered extension disables neither optimization; +- a used extension, `ClosureValidationRule`, and direct/wrapped `ValidatorAwareRule` disable exclusion pre-evaluation; +- a plain modern `ValidationRule` / `InvokableRule` does not disable exclusion pre-evaluation merely because Hypervel wraps it in `InvokableValidationRule`; +- presence batching remains enabled with those mutation-capable rules, while changed values use database-proven facts or delegate when unknown; +- changes from one submitted presence value to another submitted value use the correct fact; changes to an unsubmitted value fall back; +- parent exclusions made unresolved by a used mutator suppress only affected descendant batches. +- with `stopOnFirstFailure`, an earlier required failure prevents a later wildcard integer-to-text presence probe on every driver, reports only the earlier failure, and issues zero presence queries; +- without `stopOnFirstFailure`, the same earlier failure does not disable one grouped query for safe later text probes. Document this as the narrowness converse of the early-stop regression rather than a duplicate general batching test; +- on PostgreSQL, ordinary and wildcard/batched integer-to-text probes without early-stop both raise `QueryException`, proving that batching preserves the ordinary verifier's raw binding types. + +### 7. Make precomputed presence facts database-semantic + +Files: + +- `src/validation/src/DatabasePresenceVerifier.php` +- `src/validation/src/BatchDatabaseChecker.php` +- `src/validation/src/PrecomputedPresenceVerifier.php` +- `tests/Validation/ValidationDatabasePresenceVerifierTest.php` +- `tests/Validation/ValidationBatchDatabaseCheckerTest.php` +- `tests/Validation/ValidationPrecomputedPresenceVerifierTest.php` +- validation database integration tests described in step 11 + +#### 7.1 Key facts by the complete database query shape + +Move query-shape identity to the verifier that owns the facts. Add one shared public static `PrecomputedPresenceVerifier::lookupKey()` used by both `Validator` group construction and runtime `getCount()` / `getMultiCount()` lookup. The key contains: + +- the current connection recorded by `setConnection()`; +- table and column; +- scalar where conditions in their established order, normalized exactly as `DatabasePresenceVerifier::addWhere()` consumes them; +- the ignored ID and effective ID column only when the ignored ID is neither null nor the `NULL` sentinel. + +Do not include rule type. `exists` and `unique` without an ignored ID issue the same verifier query, and runtime verifier calls cannot distinguish them. `getMultiCount()` has no ignored-ID arguments, so its key correctly matches the no-exclusion scalar shape. Return null when any extra condition is a `Closure`; callback-bearing rules remain unbatchable and runtime calls delegate. + +Use a collision-free serialized plain-string representation rather than delimiters or a lossy hash. This state exists only for one validation execution, query shapes are small, and correctness is more important than shortening the key. Require the original `DatabasePresenceVerifierInterface` in `PrecomputedPresenceVerifier`'s constructor and forward `setConnection()` unconditionally; a connection-less fallback cannot honor connection-keyed query shapes. `Validator::getPresenceVerifier()` already keeps the connection selection and probe in one synchronous chain. Unknown facts must always delegate; a nullable fallback and synthesized zero count would incorrectly pass `unique`. + +Change `addLookup()` to accept the query key, and key all fact maps by it. A mismatched or null runtime key delegates. Delete the compensating table/column machinery completely: + +- `Validator::collectUnsafeTableColumns()` and `extractTableColumnForUnsafeCheck()`; +- `Validator::buildPresenceGroupKey()` after its callers use `lookupKey()`; +- the `$unsafeTableColumns` parameter through `BatchDatabaseChecker`; +- `BatchDatabaseChecker`'s table/column count census and stale limitation docs. + +Do not keep both collision mechanisms. Query-key ownership is smaller, supports separate batched shapes on the same column, and makes non-wildcard identical-shape probes safe once unknown values delegate under the fact model below. + +Tests: + +- build-time and runtime keys agree for exists, unique with/without ignore, connection-qualified tables, ordered wheres, and the `NULL` sentinel; +- exists and unique without an effective exclusion share a key; ignored IDs and effective ID columns change it; +- closure conditions return null and delegate; +- two different shapes on one table/column can each register and consume only their own facts; +- connection changes select the matching lookup while still reaching the fallback for unknown keys. + +#### 7.2 Query only normalizable candidates + +Normalize each concrete candidate independently. Only native strings, integers, floats, and one-dimensional arrays containing only those types are supported. Booleans, null, objects, resources, and other unsupported candidates are skipped without declining safe siblings. Date/time and `Stringable` objects must delegate unchanged: the connection owns date formatting, and the optimizer must never invoke arbitrary user code before ordered validation reaches the presence rule. Unsupported runtime probes delegate before consulting any stored fact. + +Apply the same boundary to query-shape conditions. `lookupKey()` returns null for closures and every non-scalar value other than null; it must not cast a `Stringable` condition while planning or probing. Canonical callback-free database rules already serialize supported conditions to scalar strings, while callback-bearing rules remain delegated. + +Keep database comparison normalization separate from PDO binding identity. Do not string-cast submitted SQL values: retain the raw string, integer, or float so the connection uses the same binding as the ordinary verifier. `Connection::bindValues()` binds integers as `PDO::PARAM_INT` and the other supported values as `PDO::PARAM_STR`, so use one collision-free binding key with a positional prefix: + +```php +public static function bindingKey(mixed $value): ?string +{ + $normalized = self::normalizeValue($value); + + return $normalized === null ? null : (is_int($value) ? 'i' : 's') . $normalized; +} +``` + +Keep `normalizeValue()` as the shared string comparison form for supported candidates and fetched database values. The binding key delegates to it, so the native-type boundary remains in one place. Floats and strings intentionally share the `s` form because PDO binds each as a string representation. Prefixed keys also prevent PHP from silently converting numeric-string fact-map keys to integers. + +Do not batch booleans. `Connection::prepareBindings()` converts them to integers, while `PostgresConnection` with emulated prepares converts them to `'true'` / `'false'`, and returned column representations also vary by driver/PDO mode. Delegating this marginal presence-rule shape is simpler and guarantees parity with the real verifier. It also removes the existing `false` to `''` corruption without adding a two-representation boolean scheme. + +Deduplicate candidates by binding key and retain the first raw value as its representative SQL binding. Build one comparison-string-to-binding-keys index from `substr($bindingKey, 1)`; do not allocate a tuple or value object per candidate. A successful grouped query proves every retained raw binding was accepted. A fetched equal comparison string can therefore establish an exact fact for every matching submitted binding key while still allowing PDO to return integer/numeric columns as strings. An equal-looking runtime value with an unsubmitted binding key is unknown and delegates. Objects, booleans, and date/time values are excluded before normalization, so no binding-conversion approximation is needed. + +Do not partially submit an array containing an unsupported nested item. Its eventual `getMultiCount()` must remain one coherent fallback. + +#### 7.3 Use two grouped stages + +For every grouped query shape: + +1. Query all distinct submitted binding representatives in chunks of 1,000. +2. Normalize every fetched value to its comparison string, use the comparison index to build binding-keyed exact hits, then derive binding-keyed misses. +3. If stage 1 fetched nothing, every submitted value is proven absent. +4. If the whole group has one submitted binding and stage 1 returned a nonexact representation, that sole value is already known present for scalar presence semantics; no isolation query is needed. This count must be binding-based: collapsing string `'1'` and integer `1` here caused the same order-dependent MySQL defect as candidate deduplication. +5. If stage 1 has no exact hits and multiple submitted bindings, register no facts and let runtime probes delegate. The miss set is identical to the original grouped query, so rerunning it cannot isolate a value. +6. Otherwise, if stage 1 has hits and misses, query only the misses with the identical connection, table, column, where conditions, ignore value, ID column, write PDO, and chunking. +7. If stage 2 fetched nothing, every submitted miss is proven absent. +8. Every missed binding whose comparison string is returned by stage 2 is known present for scalar semantics, even when other misses remain ambiguous. Keep these in the scalar-only `knownPresent` map rather than merging facts from two queries into the stage-1 exact map. +9. If stage 2 is non-empty and there was exactly one missed binding, that isolated miss is known present even when the stored representation differs. +10. Other misses from a non-empty multi-miss stage remain unresolved and each runtime scalar probe delegates, memoized by full query key and binding key for this execution. + +This uses the database itself to isolate collation/coercion matches. It avoids both an attacker-controlled one-query-per-ordinary-miss regression and non-portable collation emulation. Do not add recursive partitioning, thresholds, derived tables, `CASE`, or driver-specific equality logic. + +Store three plain maps per query-keyed lookup: + +```text +exactHits stage 1 returned this submitted binding's comparison string +knownPresent a single-binding query or stage 2 proved a scalar miss is present +provenAbsent a stage returned no row for this submitted binding +``` + +Anything in no map was unqueried or ambiguous and delegates. Cache only actual fallback scalar counts inside this verifier instance, nested by the same full query key as the fact maps and then by binding key. Return the real cached integer count, not a synthesized boolean. + +Expected scalar query costs: + +- all exact-existing: one grouped pass (one query per 1,000 values); +- all absent / all-new unique: one grouped pass; +- mixed exact hits and true misses: two grouped passes; +- a standalone representation mismatch: one grouped pass; a mismatch mixed with exact hits: two grouped passes; +- multiple ambiguous representation mismatches: two grouped passes plus one fallback per distinct unresolved scalar value. + +The extra stage creates no new consistency model: Laravel's unbatched probes already occur at different instants, and both grouped stages use the write PDO. + +#### 7.4 Preserve `getMultiCount()` database DISTINCT semantics + +Change `getExistingValues()` to select distinct column values and update its docblock to promise distinct stored values. This reduces duplicate transfer and makes one-chunk exact-hit facts usable for array-valued `exists` under the same database equality semantics as `distinct()->count($column)`. + +On a case-insensitive or otherwise normalizing column, SQL `DISTINCT` can collapse multiple stored string representations to one representative. A requested value can therefore have a row with the same normalized string key in the table without that representation being returned by stage 1; here, an "exact hit" means only that stage 1 returned the submitted string key as its distinct representative. The omitted submitted key enters the miss set and may require stage 2. This is why stage-2 key hits can establish scalar facts, and why the second grouped pass is expected more often on columns containing equivalent representations. + +There is one additional chunk boundary: SQL `DISTINCT` is authoritative within a query, not across independently chunked queries. Record whether stage 1 fit in one chunk. For `getMultiCount()`, derive each input's binding key and recover its comparison string from that key. Require a fact for every distinct binding key, but count present comparison strings once. This exactly matches `validateExists()`'s `count(array_unique($value))`, whose default `SORT_STRING` comparison collapses the same inputs. The existing rules then apply: + +- any unknown value delegates the whole multi-count; +- a `knownPresent` binding is usable only when the array has one distinct comparison string; otherwise delegate because it may map to the same stored value as another input; +- proven-absent values contribute zero; +- exact hits can be counted only when stage 1 fit in one distinct query; otherwise delegate to avoid double-counting database-equivalent representations returned from separate chunks. + +Otherwise delegate the whole multi-count. In particular, a stage-2-known-present value cannot be counted as a separate stored value: under a case-insensitive collation, inputs `['foo', 'Foo']` can both match one stored distinct value and must yield count 1, not 2. + +This small boolean is necessary; without it, database-equivalent exact representations returned from separate chunks could be double-counted. It is preferable to always delegating arrays, which would discard the existing wildcard array batching feature. + +Tests: + +- exact, absent, known-present, unresolved, unsupported, and unregistered scalar paths; +- `Stringable` candidates and conditions are never cast by planning or lookup; they delegate unchanged only if ordered validation reaches them, while safe scalar siblings remain batched; +- an array containing a `Stringable` delegates as a whole; +- boolean false/true candidates are never submitted to a batch and fall back only if normal execution reaches their presence rule, without disabling safe siblings; +- `DateTimeInterface` candidates are never string-cast or submitted to a batch and match ordinary grammar-formatted validation on every driver; +- fallback count memoization is execution-local and keyed by the full query shape plus binding key; prove isolation between equal-looking string/integer probes, two shapes on one table/column, and two tables sharing the same probe value; +- all-new unique values do not produce N fallbacks; +- a case-insensitive differently-cased duplicate fails `unique`; +- exists honors case, accent, trailing-space, and numeric coercion according to each real driver rather than PHP guesses; +- a mixed exact/true-miss group uses exactly two grouped passes; +- an ambiguous multi-miss group falls back only for unresolved distinct scalar probes; +- `getMultiCount()` delegates for unknown, multi-input `knownPresent`, and cross-chunk-unsafe facts while retaining the safe single-input cases; +- stage-2 exact-key hits in a multi-miss group become scalar-known facts without contaminating stage-1 multi-count facts; +- canonical exact array values still use the precomputed distinct result; +- a MySQL/MariaDB case-insensitive table containing collation-equivalent representations matches the real verifier's distinct count; +- mocked and real-driver integer/float candidates remain precomputed hits without fallback queries when PDO returns the same values as strings, including numeric/decimal column results on every driver; +- on MySQL, MariaDB, and SQLite, mixed string `'1'` and integer `1` against stored text `'1'` submit both raw bindings in one query and both pass; +- on MySQL and MariaDB, stored text `'01'` with mixed candidates in both orders preserves ordinary per-value semantics: string `'1'` is absent and integer `1` is present, with neither fact consuming the other; +- on PostgreSQL, integer-to-text probes and both orders of mixed string/integer batches raise `QueryException` rather than being deduplicated or string-cast by the optimizer; +- a distinct representative can omit a requested string representation from stage 1 without producing an incorrect scalar or multi-count fact; +- duplicate input elements preserve `count(array_unique($value))` / database distinct behavior; +- verifier restoration after success and exception remains covered. + +### 8. Restore exclusion order and wildcard authority + +Files: + +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationPreEvaluatedExclusionsTest.php` + +Drive exclusion analysis from `compiledPlans`. Only pre-evaluate when `checks[0]` is a delegated exclusion rule. `nullable`, `bail`, and `sometimes` are plan flags for the exact base validator, so they do not occupy position zero. Support all five built-in exclusion rules through their existing shared predicates: unconditional `Exclude`, `ExcludeIf`, `ExcludeUnless`, `ExcludeWith`, and `ExcludeWithout`. A resolved exclusion is an ordered hint; it must not suppress an earlier attribute or affect final cleanup unless execution reaches its exact plan position. + +Use the check's already parsed parameters. Apply the same dependent-field normalization as `validateAttribute()`: + +```php +$parameters = $this->replaceDotInParameters($check->parameters); + +if ($keys = $this->getExplicitKeys($attribute)) { + $parameters = $this->replaceAsterisksInParameters($parameters, $keys); +} +``` + +After parameter normalization, call the corresponding existing `validateExclude*()` predicate without adding a failure; a false result records the exact attribute as pre-resolved excluding and a true result records no exclusion. This reuses `parseDependentRuleParameters()` and therefore handles boolean/null coercion and non-scalar values exactly like execution instead of maintaining the current manual approximation. + +Resolve the used-mutator gate lazily when the scan finds its first exclusion. Validators without exclusions must not run `compiledPlansUseDataMutatingRules()` or perform a second full-plan scan. A used mutator makes every exclusion unresolved; an unused registered extension still has no effect. + +Scan each plan's checks once to find its first exclusion index and whether another exclusion appears later. Do not allocate `array_slice()` or repeat the same exclusion-name scan after a first-position predicate passes. + +Memoize successful boolean predicate outcomes inside this one prepass with a collision-free `serialize([$ruleName, $originalParameters, $explicitKeys])` key. The five built-in predicates read only their normalized parameters, `$this->data`, and `$this->rules`; their target attribute/value arguments are unused. Original parameters retain the dependent wildcard pattern, while the exact explicit-capture list completes the normalized identity even when different target primary patterns share captures. The map is `array` local to one pre-execution call and adds no plan, coroutine, or worker state. Keep calling `getValue($attribute)` on misses to match normal invocation. Do not cache exception deferrals or add plan-identity metadata. + +The original-data snapshot stops being authoritative after ordered execution may remove a descendant rule key. While scanning plans in declaration order, keep one local possible-exclusion-prefix set containing both resolved-excluding and unresolved/potential attributes. If the current rule-key attribute is a strict descendant of an earlier prefix, Laravel will or may remove that path at the attribute's loop entry; set one local `$dataMayDiffer = true` and skip scanning that descendant plan. Every possible prefix is also inserted into either the pre-excluded or unresolved result set, so the presence planner's ancestor checks already decline that plan and every deeper descendant. State this subset invariant in the source comment because the short-circuit depends on paired insertion. From that point, leave every later exclusion unresolved rather than evaluating it against stale original data. A resolved non-excluding result adds no prefix. Guard the ancestor walk until the prefix set is non-empty so validators without an earlier exclusion pay no dotted-path scan. Sibling leaf exclusions do not cross this boundary, so the common wildcard conditional path retains memoized pre-evaluation. Do not copy validation data, emulate a second mutable view, or build a dependency graph. + +Before the lazy mutator scan, outcome-key serialization, parameter normalization, or predicate call, mark a first-position exclusion unresolved when its delegated check has non-scalar parameters. This preserves ordinary timing for array-tuple objects and resources and avoids running user `__serialize()` / `__toString()` methods during preflight. Do not widen the exception catch or probe the value for behavior. + +Normalize dependent parameters only when the existing `dependsOnOtherFields($ruleName)` authority says to do so. Wrap that normalization and the speculative predicate call in one `try`. Catch exactly `InvalidArgumentException|ValueError` and classify the exclusion unresolved so normal ordered execution remains the authority: parameter-count checks throw the former, while too few wildcard captures make `vsprintf()` throw the latter. Do not catch `Throwable` or plain `Error`; that would hide bugs in the prepass, and unsupported non-stringable array-form field parameters have no realistic fluent or documented path. Do not duplicate the five predicates' parameter-count tables. Deferring is observable and required with `stopOnFirstFailure`: an earlier attribute failure can legitimately stop before Laravel ever reaches a malformed later exclusion, whereas throwing from the prepass would change that result. If execution does reach it, the original predicate still throws normally. Delete `parseExcludeRule()` and the numeric-segment regex `resolveWildcardConditionField()` once they have no caller. + +Return `[$preExcludedAttributes, $unresolvedExclusionAttributes]` as local sets. Delete the validator's `$preExcludedAttributes` property, its reset, the eager `array_filter()` of compiled plans, and `isPreExcludedOrDescendant()`. Pass resolved hints to the executor and both sets to the presence planner; neither belongs on a cached plan or worker/global state. + +Do not use Laravel's protected `$excludeAttributes` list as the exact-base executor's active index. The conditional benchmark activates about 3,100 leaf exclusions, so repeated list deduplication and full scans make both activation and lookup quadratic. Add one private execution-local `$activeExclusions` set for the exact base `Validator`, reset it at the start of `passes()`, and make it the sole authority for that path. Exact-base `excludeAttribute()` inserts into the set; exact-base `shouldBeExcluded()` checks the exact key and strict ancestors through `hasAttributeAncestorInSet()`. Subclasses continue to use the protected Laravel list and its existing deduplication/scan behavior. Do not dual-write: Laravel-shaped subclass `passes()` implementations can reset the protected list directly, and a private set must never gate or stale that path. Add a short property comment explaining this split. + +The exact-base executor must mirror Laravel's observable attribute order: + +1. At attribute entry, if an already active exclusion covers the attribute, remove that rule/data path and continue. +2. Apply `stopOnFirstFailure`; a later pre-resolved exclusion is not activated when execution stops before it. +3. If the exact current attribute is pre-resolved excluding, call `excludeAttribute($attribute)` and continue. Do not remove the current value yet: Laravel marks it during the exclusion rule and removes it in the final sweep, while later descendant rule keys are removed at their own entry. +4. Execute the normal optimized checks. Later-position, malformed, mutation-dependent, or stale-snapshot exclusions remain delegated and activate through `addFailure()` in declaration order. + +The final sweep uses only `shouldBeExcluded()`. Every pre-resolved exclusion actually reached by execution has been activated through `excludeAttribute()`; future hints skipped by global early-stop must not affect data. + +Presence batching stays deliberately more conservative than execution. Skip exact or descendant candidates found in the complete pre-excluded set and descendants of unresolved exclusions; a future parent may therefore forgo batching for an earlier child, but ordered execution still uses the ordinary verifier and remains correct. Same-attribute unresolved exclusions remain governed by the existing prefix walk. Do not duplicate executor-order activation inside the planner merely to recover this unusual batching opportunity. + +Tests: + +- `integer|exclude_if:foo,bar` retains the integer error even though the attribute is later excluded; +- exclusion-first still removes data before execution; +- exclusion-first supports top-level numeric attribute keys without crossing an integer/string type boundary; +- first-position `exclude`, `exclude_with`, and `exclude_without` use the same fast path and semantics as `exclude_if` / `exclude_unless`; +- a resolved non-excluding first-position exclusion keeps a following wildcard presence rule grouped, while a mutation-gated unresolved exclusion keeps that rule delegated; +- `bail`, `nullable`, and `sometimes` flags before exclusion do not prevent the fast path; +- malformed, later-position, or mutation-dependent exclusions remain in normal execution and mark only descendant batches uncertain; +- a malformed later exclusion still throws if reached, but an earlier `stopOnFirstFailure` result is not replaced by a prepass exception; +- a malformed wildcard exclusion remains an unresolved ancestor and suppresses early batching only for its descendants; +- literal numeric segment case: `data.5.items.0.value` resolves `data.5.items.*.type` with capture `0`, not literal segment `5`; +- two target fields sharing one capture reuse the same correct outcome while different captures remain isolated; one/multiple wildcard captures, mismatched counts, nested arrays, and escaped-dot field names match normal dependent-rule execution; +- parent pre-exclusion still suppresses descendants. +- an execution-time parent exclusion suppresses later descendants in exact-base and subclass validators; +- a descendant listed before its excluding parent retains its earlier failure while final data still removes the parent; +- `stopOnFirstFailure` does not activate or remove a later pre-resolved exclusion; +- two `passes()` calls on the same base validator, with restored rules/data and opposite exclusion outcomes, prove the exact-base active set resets between executions; +- when an excluded descendant rule key is removed, a later exclusion condition runs against the changed data instead of a stale prepass outcome; +- a later predicate reading the excluded attribute itself still sees it until final cleanup, while a later descendant rule sees its path removed at entry; +- a descendant presence candidate under a pre-excluded parent issues no SQL, while an unrelated wildcard group remains batched; +- a future parent exclusion may conservatively forgo an earlier descendant batch without changing its ordinary validation result. +- a first-position exclusion with a non-scalar array-tuple parameter invokes no conversion when an earlier global failure stops execution, and invokes ordinary conversion exactly once when execution reaches it. + +### 9. Reset transient state, parse once, and streamline stop checks + +Files: + +- `src/validation/src/PlanExecutor.php` +- `src/validation/src/AttributePlan.php` +- `src/validation/src/RuleCompiler.php` +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` +- `tests/Validation/ValidationRuleCompilerTest.php` + +Immediately before every real inline check, assign: + +```php +$this->numericRules = $this->defaultNumericRules; +``` + +Delegated checks already reset inside `validateAttribute()`. Do not add a dirty bit or conditional reset; assigning a three-element array is a cheap refcount operation, and extra state/branching would be worse. + +Regression: data `['field' => '123456', 'other' => 2]` with `string|gt:other|max:5`. `Gt` temporarily adds itself to `numericRules`; inline `max` must select `validation.max.string`, not `validation.max.numeric`. + +In `RuleCompiler::compile()`, parse each exploded input rule exactly once into a temporary list of parsed name/parameter pairs. Pass those pairs to `collectContext()` and pass the corresponding pair into `compileRule()` rather than calling `ValidationRuleParser::parse()` again. Safe fluent conditions were already canonicalized once by step 1; this local intermediate representation removes duplicate token parsing and stringification from the compiler's context and emission passes. + +Keep the data flow explicit and local: + +```php +$parsedRules = array_map( + static fn (mixed $rule): array => ValidationRuleParser::parse($rule), + $rules, +); +$context = self::collectContext($parsedRules); + +foreach ($rules as $index => $rule) { + self::compileRule($rule, $parsedRules[$index], $plan, $context); +} +``` + +Change `collectContext()` to consume parsed pairs and `compileRule()` to accept its pair. The temporary list is linear in one attribute's rule count, exists only during a cache miss, and replaces repeated parsing; it adds no worker-lifetime or per-validation execution state. + +The parsed pair's first element is `mixed`, not always a rule-name string: `ValidationRuleParser::parse()` returns the original object for a `RuleContract`. Keep `compileRule()`'s `RuleContract` branch ahead of any string-only path and keep `collectContext()`'s `is_string($parsedName)` guard. Before collecting inline context, build an all-delegated plan from the existing parsed pairs when any parameter is non-scalar, as required by step 3. + +When a non-scalar parameter makes the base compiler fall back to an all-delegated plan, feed its existing parsed pairs directly into delegated check emission instead of parsing the uncacheable rules again. Standalone `compileAllDelegated()` calls for validator subclasses continue parsing each rule once as they emit it; keep its public signature unchanged rather than exposing the compiler's aligned parsed-pair intermediate. For `nullable`, `bail`, and `sometimes`, set the plan flag and also emit the original delegated check. Subclass helpers consume the flags, while Laravel-compatible execution still reaches overridden `validateNullable()`, `validateBail()`, and `validateSometimes()` methods. + +Remove from `AttributePlan`: + +- `$required`; +- `$hasImplicitRule`; +- `$sizeMode` and its `SizeMode` import as part of step 4. + +Remove every compiler write and delete `RuleCompiler::isImplicitRule()` with its duplicate list. Numeric semantics remain only in `compile()`'s local context and in size `InlineCheck` parameters. `compileAllDelegated()` needs no context pre-scan, which also removes needless work for validator subclasses. + +Strengthen `AttributePlan`'s existing immutability documentation: no execution or optimizer state may be attached because cached plans are shared across attributes, requests, and concurrent coroutines. + +Keep two execution loops with separate contracts rather than branching on the validator class for every check: + +- the exact base validator retains the optimized inline/delegated loop and stops from state its plan already owns; +- validator subclasses run a small Laravel-shaped loop over delegated checks: active-exclusion entry guard, global early stop, `validateAttribute()`, post-check exclusion, then dynamic `shouldStopValidating()`. + +Neither execution loop applies a plan-level absent-`sometimes` shortcut. Inline checks already skip absent values through the shared non-implicit gate, while delegated checks retain `validateAttribute()` / `isValidatable()` as the authority. This is required because Laravel exclusion rules deliberately bypass `passesOptionalCheck()` even when `sometimes` is present. The subclass loop also preserves custom `validate*()`, `passesOptionalCheck()`, `shouldStopValidating()`, `shouldBeExcluded()`, and `removeAttribute()` behavior without adding branches to the exact-base hot loop. + +The exact-base loop stops without calling the parsing-based `shouldStopValidating()` after a message: + +1. Compute `$cleanedAttribute = $this->replacePlaceholderInString($attribute)` once for the plan. +2. Use `$plan->bail && $this->messages->has($cleanedAttribute)`. The current raw-attribute check is wrong for escaped-dot keys and is accidentally rescued by the later legacy helper. +3. Stop when `failedRules[$cleanedAttribute]` contains `uploaded`. +4. Stop when the names already recorded in `failedRules[$cleanedAttribute]` intersect the canonical validator `$implicitRules` list. The failed rule's presence proves the attribute has that implicit rule, so a preliminary `hasRule()` scan and a stored `hasImplicitRule` flag are both redundant. + +Keep Laravel's protected `shouldStopValidating()` for subclasses and the legacy benchmark loop; only the exact-base executor bypasses its repeated parsing. + +Correct escaped-dot key ownership at the same execution boundary: + +- `hasNotFailedPreviousRuleIfPresenceRule()` queries messages with `replacePlaceholderInString($attribute)`, because messages and `failedRules` use public cleaned keys; +- `addFailure()` passes `$attributeWithPlaceholders` to `excludeAttribute()`, because exclusions, rules, and validation data use internal placeholder-containing keys. + +Laravel 13.x shares both mistakes, but literal-dot attributes are supported. Keep cleaned keys for messages/failures and placeholder keys for rules/data; do not add a second conversion layer. + +Replace tests that pin dead fields with behavior or consumed-output assertions: + +- `Required` and implicit rules still execute on absent/empty attributes; +- nullable/bail/sometimes flags remain; +- size comparison and message selection come from the emitted numeric-semantics boolean plus runtime value shape; +- compiler context and emission consume one parsed-pair list without a second parse; +- parser tests, not compiler tests, prove conditional fluent closures are invoked once; +- bail, uploaded failures, and failed implicit rules stop without reparsing the attribute's rules; +- bail and implicit stopping use placeholder-cleaned escaped-dot attributes; +- subclass overrides of `shouldStopValidating()`, `passesOptionalCheck()`, `validateNullable()`, `validateBail()`, and `validateSometimes()` are reached exactly where Laravel reaches them, while the base validator never reparses through `shouldStopValidating()`; +- literal dotted and nested literal-dotted attributes do not issue `exists` / `unique` queries after an earlier failure; +- later-position exclusion on a literal-dotted attribute stops following rules, removes data through the internal key, and preserves earlier errors; +- all-delegated plans retain correct ordered behavior without stored context. + +### 10. Fix strict `date_format` round trips + +Files: + +- `src/validation/src/Concerns/ValidatesAttributes.php` +- `src/docs/validation.md` +- `tests/Validation/ValidationValidatorTest.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` + +Normalize once and compare strictly in the existing shared helper: + +```php +$stringValue = (string) $value; +$date = DateTime::createFromFormat('!' . $format, $stringValue, new DateTimeZone('UTC')); + +return $date !== false && $date->format($format) === $stringValue; +``` + +Keep the current type guard and `ValueError` handling. Both delegated `validateDateFormat()` and inline `DateFormat` already call this helper, so there is one behavior change site. Do not alter `before`, `after`, or other date comparison arms; they use a different parsing boundary. + +The change only affects noncanonical numeric-string round trips for all-numeric formats. Existing canonical/composite cases remain unchanged. + +Tests in both base and all-delegated execution paths: + +- `m` rejects `'1'` and accepts `'01'`; +- `Y` rejects `'24'` and accepts `'0024'`; +- canonical `Ymd` remains valid; +- numeric input follows its normalized string form, including a canonical `U` value; +- malformed formats remain false without leaking `ValueError`. + +Mark this as an upstream Laravel issue/PR candidate after the Hypervel fix; do not make upstream coordination part of this implementation slice. + +Document the exact-match contract beside `date_format` in the canonical validation docs. Do not add this correctness fix to the porting guide or package README: it does not change a normal porting decision, and those surfaces must not become exhaustive bug-fix diffs. + +### 11. Put database validation coverage on the existing matrix + +Files: + +- move `tests/Integration/Validation/ValidationBatchDatabaseCheckerTest.php` to `tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php` and make it abstract; +- add these exact thin concrete wrappers: + - `tests/Integration/Validation/Database/MySql/ValidationBatchDatabaseCheckerTest.php` in namespace `Hypervel\Tests\Integration\Validation\Database\MySql` with `#[RequiresDatabase('mysql')]`; + - `tests/Integration/Validation/Database/MariaDb/ValidationBatchDatabaseCheckerTest.php` in namespace `Hypervel\Tests\Integration\Validation\Database\MariaDb` with `#[RequiresDatabase('mariadb')]`; + - `tests/Integration/Validation/Database/Postgres/ValidationBatchDatabaseCheckerTest.php` in namespace `Hypervel\Tests\Integration\Validation\Database\Postgres` with `#[RequiresDatabase('pgsql')]`; + - `tests/Integration/Validation/Database/Sqlite/ValidationBatchDatabaseCheckerTest.php` in namespace `Hypervel\Tests\Integration\Validation\Database\Sqlite` with `#[RequiresDatabase('sqlite')]`. + +Each wrapper has the established empty inherited-suite shape: + +```php +#[RequiresDatabase('driver')] +class ValidationBatchDatabaseCheckerTest extends ValidationBatchDatabaseCheckerTestCase +{ +} +``` + +This is the same inherited-test pattern used by RateLimiter, NestedSet, Passkeys, and Session. Shared validation test bodies are written once in the abstract case; the four wrappers contain no duplicated tests and exist only for database-workflow discovery. + +Keep driver-neutral correctness and query-count tests in the shared abstract case. Use method-level `#[RequiresDatabase(['mysql', 'mariadb'])]` for case-insensitive collation cases and `#[RequiresDatabase('pgsql')]` for typed-column safety cases. Do not move validation tests into `tests/Integration/Database` and do not duplicate the suite per driver. + +Extend the shared fixture with a non-unique text column for `getMultiCount()` collation tests; the existing unique email column cannot hold two database-equivalent representations on MySQL/MariaDB. Keep schema creation and all shared assertions in the abstract case. + +Update `tests/Validation/ValidationDatabasePresenceVerifierTest.php::testGetExistingValuesUsesRequestedConnectionAndQueryShape()` to expect the fluent `distinct()` call before `pluck()`, rename it to state the distinct return contract, and preserve an exact result assertion. The real database matrix must insert duplicate/equivalent stored values and verify that `getExistingValues()` returns one value per database-distinct equivalence class; do not merely relax the existing mock assertion. + +The current `bin/run-database-tests.sh` already discovers `tests/Integration/Validation/Database/`; no workflow or runner edit is needed. + +### 12. Cover the newly optimized common forms in benchmarks + +Files: + +- `src/validation/src/Console/BenchmarkValidationCommand.php` +- `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` +- `tests/Validation/BenchmarkValidationCommandTest.php` + +Repair and verify the benchmark harness before changing performance-sensitive validation source, then capture the trusted baseline. The current command has valid optimized and historical execution paths, but its option handling, cache setup, workload checks, and reporting need these corrections: + +- replace the mutable static description map with one typed `SCENARIO_DESCRIPTIONS` constant; derive the `all` list from its keys, validate requested names against it with a console error and `self::FAILURE`, and remove the `buildScenario()` fallback so a description/builder mismatch fails natively; +- keep the iteration count local to `handle()` and pass it to `benchmark()`. Delete the static property, `flushState()`, and its `AfterEachTestSubscriber` call so normal test cleanup no longer autoloads the benchmark-only legacy classes; +- declare both valued options with `InputOption::VALUE_REQUIRED`; validate iterations with `FILTER_VALIDATE_INT`, reject values below one instead of silently clamping them, and keep the default when the option is omitted; +- replace scenario `rand()` calls with deterministic arithmetic that preserves the same representative value shapes without mutating the process-global random generator; +- install one concrete `DatabasePresenceVerifier` from the command application's database resolver on both benchmark validators. Production validators receive this verifier even when their rules contain no presence check, so the optimized timings must include the planner's cheap no-presence gate; no benchmark scenario should issue a database query; +- before timing each path, flush `RulePlanCache` and `ValidationRuleParser`, then run one untimed warmup. Require the optimized and legacy warmup booleans to agree; report disagreement as a console error and `self::FAILURE`. Time each path only after its own warmup so both measure long-lived-worker steady state with the caches they actually use; +- calculate the true median for odd and even iteration counts inline in `benchmark()`. Do not extract a helper solely for testing; +- divide the nonzero real validator timings directly rather than guarding the numerator while dividing by the unguarded denominator; +- report that timings are medians of the requested number of measured iterations and return `self::SUCCESS` on completion. + +Use one focused command test with `flat` and one iteration to keep it cheap. Assert a known scenario succeeds and names itself, an unknown scenario fails without silently running `simple`, and zero/non-integer iteration values fail. Do not expose private timing machinery, inject fake production scenarios, or test Symfony's own required-value error merely to reach otherwise private branches. + +After steps 1–11, add two focused scenarios. The current scenarios use only pipe-delimited string rules and all size rules have explicit sibling types: + +- a fluent-rules scenario with a representative wildcard form such as `['required', Rule::in([...]), 'max:...']`, proving parser canonicalization, plan-cache reuse, and `In` inlining are measured across fresh validators; +- a typeless-size scenario dominated by common `required|max:255` / `min` / `between` string and array values, proving the value-dispatched size path rather than the old delegated path is measured. + +Keep benchmark data and rules valid and deterministic, reuse the existing optimized-versus-legacy harness, and do not add database timing to this command. Presence batching remains pinned by deterministic integration-test query counts; mixing live database latency into the CPU benchmark would add noise rather than useful coverage. Keep the existing conditional scenario shape unchanged because its first-position wildcard `exclude_unless` directly exercises step 8. + +## Test and verification sequence + +Implement each section with its focused tests, running the touched file immediately. At coherent checkpoints run: + +```bash +vendor/bin/phpunit tests/Validation/BenchmarkValidationCommandTest.php + +vendor/bin/phpunit tests/Validation/ValidationPlanExecutorTest.php \ + tests/Validation/ValidationCompiledExecutionTest.php \ + tests/Validation/ValidationBatchDatabaseCheckerTest.php \ + tests/Validation/ValidationPrecomputedPresenceVerifierTest.php \ + tests/Validation/ValidationDatabasePresenceVerifierTest.php \ + tests/Validation/ValidationPreEvaluatedExclusionsTest.php \ + tests/Validation/ValidationRuleCompilerTest.php \ + tests/Validation/ValidationRuleParserTest.php \ + tests/Validation/ValidationRulePlanCacheTest.php + +vendor/bin/phpunit tests/Validation + +DB_CONNECTION=sqlite DB_DATABASE=/tmp/testing.sqlite \ + bin/run-database-tests.sh sqlite --filter=ValidationBatchDatabaseCheckerTest +DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=testing DB_USERNAME=root DB_PASSWORD=password \ + bin/run-database-tests.sh mysql --filter=ValidationBatchDatabaseCheckerTest +DB_CONNECTION=mariadb DB_HOST=127.0.0.1 DB_PORT=3307 DB_DATABASE=testing DB_USERNAME=root DB_PASSWORD=password \ + bin/run-database-tests.sh mariadb --filter=ValidationBatchDatabaseCheckerTest +DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=testing DB_USERNAME=postgres DB_PASSWORD=password \ + bin/run-database-tests.sh pgsql --filter=ValidationBatchDatabaseCheckerTest +``` + +After repairing its integrity and before changing validation runtime behavior, run the existing benchmark three times. Run the final expanded benchmark three times after implementation: + +```bash +php src/testbench/bin/testbench validation:benchmark --scenarios=all --iterations=15 +``` + +Compare three runs by median. Investigate any repeatable optimized-path regression above normal measurement noise, especially flat/simple scenarios affected by the inline numeric-state reset. Confirm that fluent-object and typeless-size scenarios improve as intended. Presence performance is pinned primarily by query counts because avoided database round trips dominate predicate CPU. + +Final verification: + +```bash +composer fix +``` + +Do not weaken assertions to accommodate the implementation. Any failure must be traced to the shared gate, ordered planner, database fact model, or intended strict date-format correction. + +## Acceptance checklist + +- [x] Laravel rule syntax, public APIs, rule/message order, and extension points remain compatible except for the verified upstream `date_format`, resource-valued `json`, falsey ignored-ID, and boolean database-condition bug fixes. +- [x] Pipe-delimited and array rule forms compile to the same correct behavior. +- [x] Laravel's generic safe-object canonicalization is restored; fluent `in` / `not_in` and callback-free presence rules use caching/inlining without a class allowlist. +- [x] Callback-bearing presence rules retain their objects and query callbacks, remain delegated, and produce no lookup key. +- [x] O(n) wildcard expansion, immutable worker-lifetime plan caching, and the branch-free exact-base execution loop remain intact. +- [x] Validator subclasses execute every declared rule through Laravel's delegated loop and preserve protected/public extension hooks without slowing the exact base path. +- [x] Any attribute containing a non-scalar parsed parameter uses the all-delegated path; compiler-owned inline parameters are scalar by construction. +- [x] Typeless `min` / `max` / `size` / `between` inline with runtime value dispatch; numeric semantics come only from the canonical `$defaultNumericRules`, including `Decimal`. +- [x] Common `required|integer|exists`, `required|max:255|exists`, `email|exists`, `date|exists`, and `required|array|exists` wildcard shapes remain batched. +- [x] `stopOnFirstFailure` uses ordinary presence execution so speculative SQL cannot replace the first validation failure or poison a PostgreSQL transaction; exclusion pre-evaluation remains enabled. +- [x] No invalid value is submitted merely because preflight could not prove its prefix; uncertain probes fall back only if execution reaches presence. +- [x] Boolean, object, resource, and `Stringable` presence candidates use the ordinary verifier path; the optimizer invokes no user conversion code and approximates no driver-owned binding. +- [x] SQL bindings retain their raw supported types; facts and fallback memos require the submitted PDO binding identity while fetched values use a separate string comparison form. +- [x] Precomputed facts are keyed by complete effective query shape; different connections, wheres, or unique exclusions on one table/column cannot consume one another's facts or disable each other's batches. +- [x] Pre-resolved exclusions activate only when execution reaches their exact attribute; attribute order, global early-stop, descendant removal, later dependent rules, and final cleanup match Laravel. +- [x] Pre-excluded, unresolved-parent-excluded, absent-sometimes, empty, nullable, and proven-failing values issue no presence query. +- [x] Case-insensitive/collation-equivalent `unique` values cannot false-pass. +- [x] Array-valued `exists` agrees with database `DISTINCT` semantics, including chunk boundaries. +- [x] No optimizer result is stored in a shared plan, static property, or coroutine context. +- [x] Exclusion pre-evaluation covers the five built-in exclusion rules only at a safe first position, preserves earlier failures, and resolves wildcard captures through the established authority. +- [x] Unused extensions do not suppress optimization; used validator mutators suppress only exclusion pre-evaluation and affected descendant batches. +- [x] Inline messages cannot inherit transient numeric state. +- [x] Resource-valued JSON fails cleanly in inline and delegated execution, with no duplicate JSON predicate. +- [x] Conditional fluent closures are evaluated once during parser explosion, matching Laravel, and compiler context/emission share one local parsed-pair list. +- [x] Compiled bail/uploaded/implicit stopping uses existing plan/failure state and placeholder-cleaned attribute keys without reparsing rules. +- [x] Escaped-dot message/failure keys and internal rule/data keys remain in their correct domains for presence and exclusion behavior. +- [x] `SizeMode`, `DatabasePresenceRule`, presence-metadata methods, dead plan fields/compiler writes, duplicate implicit-rule knowledge, and obsolete helpers/tests/comments are removed. +- [x] Every retained source comment and docblock describes the final design; no superseded optimizer explanation remains. +- [x] The validation database suite runs through the existing MySQL, MariaDB, PostgreSQL, and SQLite workflow discovery. +- [x] The benchmark rejects invalid input, verifies optimized/legacy result agreement, measures deterministic warm-cache workloads, and reports a correct median without process-global command state. +- [x] Focused, full validation, database-matrix, benchmark, static-analysis, formatting, and final repository checks pass. diff --git a/src/docs/validation.md b/src/docs/validation.md index 4becb47dd4..a6a819c713 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -1796,7 +1796,7 @@ The field under validation must be equal to the given date. The dates will be pa #### date_format:_format_,... -The field under validation must match one of the given _formats_. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class. +The field under validation must match one of the given _formats_. Matching is exact, so padded tokens such as `m` reject unpadded values such as `1`. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class. For convenience, date-based rules may be constructed using the fluent `date` rule builder: diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index dabb68c32f..3e30770c6c 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -296,7 +296,6 @@ protected function flushFrameworkState(): void \Hypervel\Testing\TestResponse::flushState(); \Hypervel\Testing\TestView::flushState(); \Hypervel\Translation\Translator::flushState(); - \Hypervel\Validation\Console\BenchmarkValidationCommand::flushState(); \Hypervel\Validation\Rule::flushState(); \Hypervel\Validation\Rules\Date::flushState(); \Hypervel\Validation\Rules\Email::flushState(); diff --git a/src/validation/src/AttributePlan.php b/src/validation/src/AttributePlan.php index 45dade3c84..2d1996f95e 100644 --- a/src/validation/src/AttributePlan.php +++ b/src/validation/src/AttributePlan.php @@ -4,32 +4,22 @@ namespace Hypervel\Validation; -use Hypervel\Validation\Enums\SizeMode; - /** * Compiled validation plan for a single attribute. * * Contains pre-resolved flags and the check list (inline + delegated). - * Immutable after compilation — safe to cache worker-lifetime and share - * by reference across requests without cloning. Per-request state (like - * which attributes are excluded) lives on the Validator instance, not here. + * Plans are immutable after compilation and shared by reference across + * attributes, requests, and concurrent coroutines. Execution and optimizer + * state must remain on the Validator instance rather than being attached here. */ final class AttributePlan { - public bool $required = false; - public bool $nullable = false; public bool $bail = false; public bool $sometimes = false; - /** Pre-resolved from sibling type rules. Null if ambiguous or no type rule present. */ - public ?SizeMode $sizeMode = null; - - /** Whether any check is an implicit rule (runs even when attribute is absent). */ - public bool $hasImplicitRule = false; - /** @var list */ public array $checks = []; } diff --git a/src/validation/src/BatchDatabaseChecker.php b/src/validation/src/BatchDatabaseChecker.php index d2cd0cbf82..4dbc0f3b7c 100644 --- a/src/validation/src/BatchDatabaseChecker.php +++ b/src/validation/src/BatchDatabaseChecker.php @@ -4,129 +4,171 @@ namespace Hypervel\Validation; -use Stringable; - /** - * Execute batched database queries for wildcard exists/unique validation. - * - * This is a thin query layer — rule interpretation and metadata extraction - * happen on the Validator (using its own parseTable, getQueryColumn, etc.). - * This class only receives pre-built groups and runs the batch queries. + * Query wildcard database-presence candidates in groups. * - * Builds a PrecomputedPresenceVerifier that can be set on the validator, - * keeping original rule objects intact for correct error message resolution. + * The validator owns rule interpretation and ordered candidate selection. This + * class turns each complete query shape into database-proven execution-local + * facts consumed by PrecomputedPresenceVerifier. */ final class BatchDatabaseChecker { private const int CHUNK_SIZE = 1000; /** - * Build a PrecomputedPresenceVerifier from grouped rules. + * Build a precomputed verifier from grouped candidates. * - * @param array, values: list}> $groups - * @param array $unsafeTableColumns table:column pairs that must not be - * precomputed because other rules use the - * same pair with a different query shape + * @param array, + * ignore: null|int|string, + * idColumn: ?string + * }, + * values: list + * }> $groups */ - public static function buildVerifier(array $groups, DatabasePresenceVerifier $presenceVerifier, array $unsafeTableColumns = []): ?PrecomputedPresenceVerifier + public static function buildVerifier(array $groups, DatabasePresenceVerifier $presenceVerifier): ?PrecomputedPresenceVerifier { $verifier = new PrecomputedPresenceVerifier($presenceVerifier); - self::registerLookups($verifier, $presenceVerifier, $groups, $unsafeTableColumns); + foreach ($groups as $lookupKey => $group) { + self::registerLookup($verifier, $presenceVerifier, $lookupKey, $group['meta'], $group['values']); + } return $verifier->hasLookups() ? $verifier : null; } /** - * Batch-query and register lookups on a verifier for grouped rules. - * - * If multiple groups collapse to the same table:column (different query - * shapes for the same target), none are registered — they all fall back - * to the real verifier. The PrecomputedPresenceVerifier API only keys - * by table:column, so it cannot distinguish between different query shapes. + * Query and register database-proven facts for one query shape. * - * @param array $unsafeTableColumns table:column pairs blocked from precomputing + * @param array{ + * connection: ?string, + * table: string, + * column: string, + * wheres: array, + * ignore: null|int|string, + * idColumn: ?string + * } $meta + * @param list $values */ - private static function registerLookups( + private static function registerLookup( PrecomputedPresenceVerifier $verifier, DatabasePresenceVerifier $presenceVerifier, - array $groups, - array $unsafeTableColumns = [], + string $lookupKey, + array $meta, + array $values, ): void { - // Detect table:column collisions — multiple query shapes targeting the - // same table:column cannot be safely stored in the verifier. - $tableColumnCounts = []; - foreach ($groups as $group) { - $verifierKey = $group['meta']['table'] . ':' . $group['meta']['column']; - $tableColumnCounts[$verifierKey] = ($tableColumnCounts[$verifierKey] ?? 0) + 1; + $representativeValues = self::normalizeCandidates($values); + + if ($representativeValues === []) { + return; } - foreach ($groups as $group) { - $values = self::uniqueStringValues($group['values']); + $stageOneSingleChunk = count($representativeValues) <= self::CHUNK_SIZE; + $stageOneValues = self::queryValues( + $presenceVerifier, + $meta, + array_values($representativeValues), + ); + $comparisonIndex = []; - if ($values === null) { - continue; - } + foreach (array_keys($representativeValues) as $bindingKey) { + $comparisonIndex[substr($bindingKey, 1)][] = $bindingKey; + } - if ($values === []) { - continue; - } + $exactHits = []; + $knownPresent = []; + $provenAbsent = []; - $meta = $group['meta']; - $verifierKey = $meta['table'] . ':' . $meta['column']; + foreach ($stageOneValues as $value) { + $normalizedValue = PrecomputedPresenceVerifier::normalizeValue($value); - // Skip if multiple batch groups target the same table:column, - // or if non-batched rules also use this table:column pair. - if ($tableColumnCounts[$verifierKey] > 1 || isset($unsafeTableColumns[$verifierKey])) { + if ($normalizedValue === null) { continue; } - $fetched = self::queryValues( - $presenceVerifier, - $meta['connection'], - $meta['table'], - $meta['column'], - $values, - $meta['wheres'], - $meta['type'] === 'unique' ? $meta['ignore'] : null, - $meta['type'] === 'unique' ? $meta['idColumn'] : 'id', - ); + // Query success proves each retained PDO binding was accepted. A returned + // equal string form is therefore exact for every matching submitted binding. + foreach ($comparisonIndex[$normalizedValue] ?? [] as $bindingKey) { + $exactHits[$bindingKey] = true; + } + } + + $misses = array_diff_key($representativeValues, $exactHits); + + // An isolation query is useful only after exact hits shrink the original candidate set. + if ($stageOneValues === []) { + $provenAbsent = array_fill_keys(array_keys($representativeValues), true); + } elseif (count($representativeValues) === 1 && $exactHits === []) { + $knownPresent[array_key_first($representativeValues)] = true; + } elseif ($exactHits !== [] && $misses !== []) { + $stageTwoValues = self::queryValues($presenceVerifier, $meta, array_values($misses)); + + if ($stageTwoValues === []) { + $provenAbsent = array_fill_keys(array_keys($misses), true); + } else { + foreach ($stageTwoValues as $value) { + $normalizedValue = PrecomputedPresenceVerifier::normalizeValue($value); + + if ($normalizedValue === null) { + continue; + } + + foreach ($comparisonIndex[$normalizedValue] ?? [] as $bindingKey) { + if (isset($misses[$bindingKey])) { + $knownPresent[$bindingKey] = true; + } + } + } - $verifier->addLookup($meta['table'], $meta['column'], $fetched); + if (count($misses) === 1) { + $knownPresent[array_key_first($misses)] = true; + } + } } + + $verifier->addLookup( + $lookupKey, + $exactHits, + $knownPresent, + $provenAbsent, + $stageOneSingleChunk, + ); } /** - * Run the batched whereIn query and return matching values. + * Run chunked queries for one database query shape. * - * Replays scalar where conditions matching DatabasePresenceVerifier::addWhere() - * behavior. Uses write PDO to match the presence verifier's behavior. - * - * @param array $values - * @param array $wheres Key => value pairs (column => value) - * @return array + * @param array{ + * connection: ?string, + * table: string, + * column: string, + * wheres: array, + * ignore: null|int|string, + * idColumn: ?string + * } $meta + * @param list $values + * @return list */ private static function queryValues( DatabasePresenceVerifier $presenceVerifier, - ?string $connection, - string $table, - string $column, + array $meta, array $values, - array $wheres, - mixed $ignore = null, - string $idColumn = 'id', ): array { $results = []; foreach (array_chunk($values, self::CHUNK_SIZE) as $chunk) { array_push($results, ...$presenceVerifier->getExistingValues( - $table, - $column, + $meta['table'], + $meta['column'], $chunk, - $connection, - $ignore, - $idColumn, - $wheres, + $meta['connection'], + $meta['ignore'], + $meta['idColumn'], + $meta['wheres'], )); } @@ -134,25 +176,36 @@ private static function queryValues( } /** - * Deduplicate and cast values to strings for batch queries. + * Normalize candidates while retaining the first raw SQL binding. + * + * An unsupported array item rejects only that concrete array candidate; + * safe siblings in the same query-shape group remain batchable. * - * @param array $values - * @return null|list + * @param list $values + * @return array */ - private static function uniqueStringValues(array $values): ?array + private static function normalizeCandidates(array $values): array { - $normalized = []; + $representativeValues = []; foreach ($values as $value) { + $candidateValues = []; + foreach (is_array($value) ? $value : [$value] as $item) { - if (! is_scalar($item) && ! $item instanceof Stringable) { - return null; + $bindingKey = PrecomputedPresenceVerifier::bindingKey($item); + + if ($bindingKey === null) { + continue 2; } - $normalized[] = (string) $item; + $candidateValues[$bindingKey] ??= $item; + } + + foreach ($candidateValues as $bindingKey => $rawValue) { + $representativeValues[$bindingKey] ??= $rawValue; } } - return array_values(array_unique($normalized, SORT_STRING)); + return $representativeValues; } } diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 9e2ff5a704..da49e23fda 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -26,7 +26,6 @@ use Hypervel\Support\Facades\Date; use Hypervel\Support\Json; use Hypervel\Support\Str; -use Hypervel\Validation\Enums\SizeMode; use Hypervel\Validation\FakeDnsGetRecordWrapper; use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Unique; @@ -617,9 +616,10 @@ protected function matchesDateFormat(mixed $value, string $format): bool } try { - $date = DateTime::createFromFormat('!' . $format, (string) $value, new DateTimeZone('UTC')); + $stringValue = (string) $value; + $date = DateTime::createFromFormat('!' . $format, $stringValue, new DateTimeZone('UTC')); - return $date !== false && $date->format($format) == $value; + return $date !== false && $date->format($format) === $stringValue; } catch (ValueError) { return false; } @@ -1102,9 +1102,16 @@ protected function getUniqueExtra(array $parameters): array /** * Parse the connection / table for the unique / exists rules. + * + * @return array{0: ?string, 1: string, 2: ?string} */ public function parseTable(string $table): array { + if (isset($this->parsedTables[$table])) { + return $this->parsedTables[$table]; + } + + $tableParameter = $table; [$connection, $table] = str_contains($table, '.') ? explode('.', $table, 2) : [null, $table]; if (str_contains($table, '\\') && class_exists($table) && is_a($table, Model::class, true)) { @@ -1120,7 +1127,7 @@ public function parseTable(string $table): array $idColumn = $model->getKeyName(); } - return [$connection, $table, $idColumn ?? null]; + return $this->parsedTables[$tableParameter] = [$connection, $table, $idColumn ?? null]; } /** @@ -1517,11 +1524,7 @@ public function validateMacAddress(string $attribute, mixed $value): bool */ public function validateJson(string $attribute, mixed $value): bool { - if (is_array($value) || is_null($value)) { - return false; - } - - if (! is_scalar($value) && ! method_exists($value, '__toString')) { + if (! is_scalar($value) && ! $value instanceof Stringable) { return false; } @@ -2474,42 +2477,20 @@ protected function isValidUuid(string $value): bool */ protected function getSize(string $attribute, mixed $value): float|int|string { - $hasNumeric = $this->hasRule($attribute, $this->numericRules); - - // This method will determine if the attribute is a number, string, or file and - // return the proper size accordingly. If it is a number, then number itself - // is the size. If it is a file, we take kilobytes, and for a string the - // entire length of the string will be considered the attribute size. - if (is_numeric($value) && $hasNumeric) { - return $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value)); - } - if (is_array($value)) { - return count($value); - } - if ($value instanceof SplFileInfo) { - return $value->getSize() / 1024; - } - - return mb_strlen((string) $value); + return $this->sizeOf( + $attribute, + $value, + $this->hasRule($attribute, $this->numericRules), + ); } /** - * Compute a value's "size" given a pre-resolved mode. - * - * Mirrors getSize() but takes an explicit SizeMode instead of scanning - * sibling rules at runtime. Uses value-first dispatch: when mode is - * Numeric AND the value is numeric, returns the trimmed numeric value; - * otherwise falls through to array count / file size / string length. - * - * This ordering is load-bearing: a mode-first version would throw - * NumberFormatException when a `min:X|numeric` rule runs before the - * numeric type check with a non-numeric value (e.g., 'abc'). getSize() - * falls back to string length in that situation; this helper must match. + * Compute the validation size from numeric semantics and runtime value shape. */ - protected function sizeOf(mixed $value, SizeMode $mode): float|int|string + protected function sizeOf(string $attribute, mixed $value, bool $numeric): float|int|string { - if ($mode === SizeMode::Numeric && is_numeric($value)) { - return $this->trim($value); + if ($numeric && is_numeric($value)) { + return $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value)); } if (is_array($value)) { @@ -2613,9 +2594,22 @@ protected function trim(mixed $value): mixed */ protected function ensureExponentWithinAllowedRange(string $attribute, mixed $value): mixed { + if (! is_numeric($value)) { + return $value; + } + + if (is_float($value) && ! is_finite($value)) { + // Downstream size comparisons stringify the result, but PHP warns when NAN is cast. + return match (true) { + is_nan($value) => 'NAN', + $value > 0 => 'INF', + default => '-INF', + }; + } + $stringValue = (string) $value; - if (! is_numeric($value) || ! Str::contains($stringValue, 'e', ignoreCase: true)) { + if (! Str::contains($stringValue, 'e', ignoreCase: true)) { return $value; } diff --git a/src/validation/src/Console/BenchmarkValidationCommand.php b/src/validation/src/Console/BenchmarkValidationCommand.php index eafd3c956c..1971421a10 100644 --- a/src/validation/src/Console/BenchmarkValidationCommand.php +++ b/src/validation/src/Console/BenchmarkValidationCommand.php @@ -5,14 +5,19 @@ namespace Hypervel\Validation\Console; use Hypervel\Console\Command; +use Hypervel\Contracts\Translation\Translator; use Hypervel\Contracts\Validation\CompilableRules; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Support\Arr; use Hypervel\Support\MessageBag; use Hypervel\Support\Str; +use Hypervel\Validation\DatabasePresenceVerifier; +use Hypervel\Validation\Rule; use Hypervel\Validation\RulePlanCache; use Hypervel\Validation\ValidationData; use Hypervel\Validation\ValidationRuleParser; use Hypervel\Validation\Validator; +use LogicException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -41,50 +46,106 @@ class BenchmarkValidationCommand extends Command * * @var array */ - private static array $scenarioDescriptions = [ + private const array SCENARIO_DESCRIPTIONS = [ 'simple' => '500 items × 7 fields (string, email, integer, in, alpha_num, numeric, nullable)', 'nested' => '1,000 orders × 5 nested line items (string, integer, numeric)', 'conditional' => '100 items × 47 conditional fields (exclude_unless, string, max)', 'flat' => '3-field login form (email, string, boolean)', + 'fluent' => '500 items × 2 fluent-rule fields (Rule::in, Rule::notIn, max)', + 'typeless-size' => '500 items × 3 typeless size fields (string and array min, max, between, size)', ]; - /** - * The number of iterations per scenario. - */ - private static int $iterations = 0; - /** * Execute the console command. */ public function handle(): int { - $scenarios = $this->option('scenarios'); + $scenarios = trim((string) $this->option('scenarios')); $scenarioList = $scenarios === 'all' - ? ['simple', 'nested', 'conditional', 'flat'] - : explode(',', $scenarios); + ? array_keys(self::SCENARIO_DESCRIPTIONS) + : array_map(trim(...), explode(',', $scenarios)); - self::$iterations = max(1, (int) $this->option('iterations')); + foreach ($scenarioList as $scenario) { + if (! array_key_exists($scenario, self::SCENARIO_DESCRIPTIONS)) { + $this->error("Invalid scenario: {$scenario}. Available: " . implode(', ', array_keys(self::SCENARIO_DESCRIPTIONS))); + + return self::FAILURE; + } + } + + $iterationsOption = $this->option('iterations'); + $iterations = filter_var($iterationsOption, FILTER_VALIDATE_INT); + + if ($iterations === false || $iterations < 1) { + $this->error("Invalid iterations: {$iterationsOption}. Must be a positive integer."); + + return self::FAILURE; + } $this->components->info('Hypervel Validation Benchmark'); + /** @var Translator $translator */ $translator = $this->hypervel->make('translator'); + /** @var ConnectionResolverInterface $database */ + $database = $this->hypervel->make('db'); + $presenceVerifier = new DatabasePresenceVerifier($database); $results = []; foreach ($scenarioList as $scenario) { - $scenario = trim($scenario); - $description = self::$scenarioDescriptions[$scenario] ?? ''; + $description = self::SCENARIO_DESCRIPTIONS[$scenario]; $this->line(" Benchmarking {$scenario} ({$description})"); [$data, $rules] = $this->buildScenario($scenario); RulePlanCache::flushState(); - $optimizedMs = $this->benchmark(fn () => (new Validator($translator, $data, $rules))->passes()); + ValidationRuleParser::flushState(); + $optimizedPassed = $this->makeValidator( + Validator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(); + $optimizedMs = $this->benchmark( + fn () => $this->makeValidator( + Validator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(), + $iterations, + ); RulePlanCache::flushState(); - $legacyMs = $this->benchmark(fn () => (new LegacyValidator($translator, $data, $rules))->passes()); + ValidationRuleParser::flushState(); + $legacyPassed = $this->makeValidator( + LegacyValidator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(); + + if ($optimizedPassed !== $legacyPassed) { + $this->error("Optimized and legacy validation disagree for scenario: {$scenario}."); + + return self::FAILURE; + } + + $legacyMs = $this->benchmark( + fn () => $this->makeValidator( + LegacyValidator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(), + $iterations, + ); - $speedup = $legacyMs > 0 ? round($legacyMs / $optimizedMs, 1) : 0; + $speedup = round($legacyMs / $optimizedMs, 1); $results[] = [ $scenario, @@ -101,28 +162,50 @@ public function handle(): int $this->components->bulletList([ 'Optimized — compiled execution with inline checks, plan caching, pre-evaluated excludes', 'Legacy — pre-optimization baseline with original validateAttribute() loop and O(n²) wildcard expansion', + 'Timings — median of ' . $iterations . ' measured ' . ($iterations === 1 ? 'iteration' : 'iterations') . ' after one untimed warmup', 'Speedup — how many times faster the optimized path is (higher is better)', ]); - return 0; + return self::SUCCESS; } /** * Run a callable N times and return the median time in milliseconds. */ - private function benchmark(callable $callback): float + private function benchmark(callable $callback, int $iterations): float { $times = []; - for ($i = 0; $i < self::$iterations; ++$i) { + for ($i = 0; $i < $iterations; ++$i) { $start = hrtime(true); $callback(); $times[] = (hrtime(true) - $start) / 1_000_000; } sort($times); + $middle = intdiv(count($times), 2); - return $times[(int) (count($times) / 2)]; + return count($times) % 2 === 0 + ? ($times[$middle - 1] + $times[$middle]) / 2 + : $times[$middle]; + } + + /** + * Build a validator with production presence-verifier wiring. + * + * @param class-string $validatorClass + */ + private function makeValidator( + string $validatorClass, + Translator $translator, + array $data, + array $rules, + DatabasePresenceVerifier $presenceVerifier, + ): Validator { + $validator = new $validatorClass($translator, $data, $rules); + $validator->setPresenceVerifier($presenceVerifier); + + return $validator; } /** @@ -137,7 +220,9 @@ private function buildScenario(string $name): array 'nested' => $this->nestedScenario(), 'conditional' => $this->conditionalScenario(), 'flat' => $this->flatScenario(), - default => $this->simpleScenario(), + 'fluent' => $this->fluentScenario(), + 'typeless-size' => $this->typelessSizeScenario(), + default => throw new LogicException("Unknown benchmark scenario [{$name}]."), }; } @@ -151,10 +236,10 @@ private function simpleScenario(): array $items[] = [ 'name' => 'Item ' . $i, 'email' => "user{$i}@example.com", - 'age' => rand(18, 80), + 'age' => 18 + ($i % 63), 'status' => 'active', 'code' => 'ABC' . $i, - 'score' => rand(1, 100), + 'score' => 1 + ($i % 100), 'notes' => 'Some notes for item ' . $i, ]; } @@ -184,8 +269,8 @@ private function nestedScenario(): array for ($j = 0; $j < 5; ++$j) { $items[] = [ 'sku' => 'SKU-' . $i . '-' . $j, - 'quantity' => rand(1, 10), - 'price' => rand(100, 10000) / 100, + 'quantity' => 1 + (($i + $j) % 10), + 'price' => (100 + (($i * 5 + $j) % 9901)) / 100, ]; } $orders[] = ['items' => $items]; @@ -235,22 +320,63 @@ private function flatScenario(): array } /** - * Get the console command options. + * 500 items × 2 fluent-rule fields. */ - protected function getOptions(): array + private function fluentScenario(): array { + $statuses = ['active', 'inactive', 'pending']; + $items = []; + + for ($index = 0; $index < 500; ++$index) { + $items[] = [ + 'status' => $statuses[$index % count($statuses)], + 'role' => $index % 2 === 0 ? 'member' : 'editor', + ]; + } + + return [ + ['items' => $items], + [ + 'items.*.status' => ['required', Rule::in($statuses), 'max:16'], + 'items.*.role' => ['required', Rule::notIn(['blocked', 'banned']), 'max:16'], + ], + ]; + } + + /** + * 500 items × 3 typeless size fields. + */ + private function typelessSizeScenario(): array + { + $items = []; + + for ($index = 0; $index < 500; ++$index) { + $items[] = [ + 'name' => "Item {$index}", + 'code' => "CODE-{$index}", + 'tags' => ['alpha', 'beta', 'gamma'], + ]; + } + return [ - ['scenarios', null, InputOption::VALUE_OPTIONAL, 'Comma-separated scenario names or "all"', 'all'], - ['iterations', null, InputOption::VALUE_OPTIONAL, 'Number of iterations per scenario', '5'], + ['items' => $items], + [ + 'items.*.name' => 'required|min:2|max:255|between:2,255', + 'items.*.code' => 'required|min:2|max:32|between:2,32', + 'items.*.tags' => 'required|min:1|max:5|between:1,5|size:3', + ], ]; } /** - * Flush all static state. + * Get the console command options. */ - public static function flushState(): void + protected function getOptions(): array { - self::$iterations = 0; + return [ + ['scenarios', null, InputOption::VALUE_REQUIRED, 'Comma-separated scenario names or "all"', 'all'], + ['iterations', null, InputOption::VALUE_REQUIRED, 'Number of iterations per scenario', '5'], + ]; } } diff --git a/src/validation/src/Contracts/DatabasePresenceRule.php b/src/validation/src/Contracts/DatabasePresenceRule.php deleted file mode 100644 index abc19a43b5..0000000000 --- a/src/validation/src/Contracts/DatabasePresenceRule.php +++ /dev/null @@ -1,25 +0,0 @@ ->, using: array, ignore?: mixed, idColumn?: string} - */ - public function presenceMetadata(): array; -} diff --git a/src/validation/src/DatabasePresenceVerifier.php b/src/validation/src/DatabasePresenceVerifier.php index 0ddd44cb7e..fdecf20f47 100644 --- a/src/validation/src/DatabasePresenceVerifier.php +++ b/src/validation/src/DatabasePresenceVerifier.php @@ -50,7 +50,7 @@ public function getMultiCount(string $collection, string $column, array $values, } /** - * Get the existing values from a collection. + * Get the distinct existing values from a collection. * * @param array $values * @param array $extra @@ -75,7 +75,7 @@ public function getExistingValues( } /** @var array */ - return $this->addConditions($query, $extra)->pluck($column)->all(); + return $this->addConditions($query, $extra)->distinct()->pluck($column)->all(); } /** diff --git a/src/validation/src/DelegatedCheck.php b/src/validation/src/DelegatedCheck.php index c65c0fe630..a99270400a 100644 --- a/src/validation/src/DelegatedCheck.php +++ b/src/validation/src/DelegatedCheck.php @@ -12,13 +12,12 @@ */ final readonly class DelegatedCheck { + public bool $parametersAreScalar; + /** * @param string $ruleName Parsed rule name (e.g., 'Exists', 'Required'). Empty for * Rule objects dispatched via validateUsingCustomRule(). * @param array $parameters parsed rule parameters - * @param null|object $ruleObject The original rule object (RuleContract, Exists, Unique, etc.). - * Typed as `object` (not RuleContract) because Exists/Unique - * implement Stringable, not RuleContract. * @param mixed $originalRule The raw rule as it appears in the exploded rules array. * Set as $this->currentRule before dispatch so validateExists/ * validateUnique can check `$this->currentRule instanceof Exists`. @@ -26,9 +25,12 @@ public function __construct( public string $ruleName, public array $parameters, - public ?object $ruleObject = null, public mixed $originalRule = null, ) { + $this->parametersAreScalar = array_all( + $parameters, + static fn (mixed $parameter): bool => is_scalar($parameter), + ); } /** diff --git a/src/validation/src/Enums/CheckType.php b/src/validation/src/Enums/CheckType.php index 9b64e78f9c..8edda0ef98 100644 --- a/src/validation/src/Enums/CheckType.php +++ b/src/validation/src/Enums/CheckType.php @@ -14,10 +14,13 @@ * 1. A compiler case in RuleCompiler::tryInline() that emits this CheckType * 2. A runner arm in PlanExecutor::executeInline() that handles it * 3. A rule name mapping in ruleName() below + * 4. A reviewed PlanExecutor::canPreflightInline() safety decision * * Forgetting (2) fails PHPStan (exhaustive match) or throws UnhandledMatchError * at runtime. Forgetting (3) fails PHPStan (exhaustive match, no default arm). * Forgetting (1) is harmless — the rule simply flows through DelegatedCheck. + * Preflight permits a case only when repeating it cannot invoke user code, + * perform I/O, emit warnings, or throw for a supported candidate value. */ enum CheckType { diff --git a/src/validation/src/Enums/SizeMode.php b/src/validation/src/Enums/SizeMode.php deleted file mode 100644 index 80a85c23f1..0000000000 --- a/src/validation/src/Enums/SizeMode.php +++ /dev/null @@ -1,18 +0,0 @@ - $compiledPlans + * @param array $preExcludedAttributes */ - protected function executeCompiledPlans(array $compiledPlans): void + protected function executeCompiledPlans(array $compiledPlans, array $preExcludedAttributes): void { foreach ($compiledPlans as $attribute => $plan) { $attribute = (string) $attribute; - if (isset($this->preExcludedAttributes[$attribute])) { - $this->excludeAttribute($attribute); + if ($this->shouldBeExcluded($attribute)) { + $this->removeAttribute($attribute); continue; } @@ -75,10 +73,13 @@ protected function executeCompiledPlans(array $compiledPlans): void break; } - if ($plan->sometimes && ! Arr::has($this->data, $attribute)) { + if (isset($preExcludedAttributes[$attribute])) { + $this->excludeAttribute($attribute); continue; } + $cleanedAttribute = $this->replacePlaceholderInString($attribute); + foreach ($plan->checks as $check) { if ($check instanceof InlineCheck) { // Fresh read per check — matches validateAttribute()'s per-rule @@ -87,22 +88,17 @@ protected function executeCompiledPlans(array $compiledPlans): void $value = $this->getValue($attribute); $exists = Arr::has($this->data, $attribute); - if ($value instanceof UploadedFile && ! $value->isValid() - && $this->hasRule($attribute, array_merge($this->fileRules, $this->implicitRules)) - ) { + if ($this->shouldFailInvalidUpload($attribute, $value)) { $this->addFailure($attribute, 'uploaded', []); break; } - // All InlineChecks are non-implicit. For non-implicit rules: - // empty string → skip, null + nullable → skip, absent → skip. - if (! $exists || (is_string($value) && trim($value) === '')) { - continue; - } - if ($plan->nullable && $value === null) { + if ($this->shouldSkipNonImplicitCheck($plan, $value, $exists)) { continue; } + $this->numericRules = $this->defaultNumericRules; + if (! $this->executeInline($check, $value, $attribute)) { $this->addFailure($attribute, $check->getRuleName(), $check->parameters); } @@ -118,17 +114,132 @@ protected function executeCompiledPlans(array $compiledPlans): void break; } - if ($plan->bail && $this->messages->has($attribute)) { + if ($plan->bail && $this->messages->has($cleanedAttribute)) { break; } - if ($this->messages->isNotEmpty() && $this->shouldStopValidating($attribute)) { + if (isset($this->failedRules[$cleanedAttribute])) { + $failedRuleNames = array_keys($this->failedRules[$cleanedAttribute]); + + if (in_array('uploaded', $failedRuleNames, true) + || array_intersect($failedRuleNames, $this->implicitRules) + ) { + break; + } + } + } + } + } + + /** + * Execute all-delegated plans for a Validator subclass. + * + * @param array $compiledPlans + */ + protected function executeDelegatedPlans(array $compiledPlans): void + { + foreach ($compiledPlans as $attribute => $plan) { + $attribute = (string) $attribute; + + if ($this->shouldBeExcluded($attribute)) { + $this->removeAttribute($attribute); + continue; + } + + if ($this->stopOnFirstFailure && $this->messages->isNotEmpty()) { + break; + } + + foreach ($plan->checks as $check) { + /** @var DelegatedCheck $check */ + $this->validateAttribute($attribute, $check->originalRule); + + if ($this->shouldBeExcluded($attribute) + || $this->shouldStopValidating($attribute) + ) { break; } } } } + /** + * Determine if a non-implicit check should be skipped. + */ + protected function shouldSkipNonImplicitCheck(AttributePlan $plan, mixed $value, bool $exists): bool + { + return ! $exists + || (is_string($value) && trim($value) === '') + || ($plan->nullable && $value === null); + } + + /** + * Determine if an invalid upload should fail before rule execution. + */ + protected function shouldFailInvalidUpload(string $attribute, mixed $value): bool + { + return $value instanceof UploadedFile + && ! $value->isValid() + && $this->hasRule($attribute, array_merge($this->fileRules, $this->implicitRules)); + } + + /** + * Determine if an inline check is safe to repeat during presence preflight. + */ + protected function canPreflightInline(InlineCheck $check, mixed $value): bool + { + if (is_object($value) || is_resource($value)) { + return false; + } + + return match ($check->type) { + CheckType::TypeString, + CheckType::TypeNumeric, + CheckType::TypeInteger, + CheckType::TypeIntegerStrict, + CheckType::TypeBoolean, + CheckType::TypeArray, + CheckType::Email, + CheckType::Url, + CheckType::Ip, + CheckType::Ipv4, + CheckType::Ipv6, + CheckType::Uuid, + CheckType::Ulid, + CheckType::Json, + CheckType::Ascii, + CheckType::HexColor, + CheckType::MacAddress, + CheckType::Alpha, + CheckType::AlphaAscii, + CheckType::AlphaDash, + CheckType::AlphaDashAscii, + CheckType::AlphaNum, + CheckType::AlphaNumAscii, + CheckType::Lowercase, + CheckType::Uppercase, + CheckType::Digits, + CheckType::DigitsBetween, + CheckType::MinDigits, + CheckType::MaxDigits, + CheckType::StartsWith, + CheckType::EndsWith, + CheckType::DoesntStartWith, + CheckType::DoesntEndWith, + CheckType::In, + CheckType::NotIn, + CheckType::IsDate, + CheckType::DateFormat => true, + CheckType::SizeMin, + CheckType::SizeMax, + CheckType::SizeBetween, + CheckType::SizeExact => ! ($check->param['numeric'] && is_numeric($value)) + || ((! is_float($value) || is_finite($value)) + && ! Str::contains((string) $value, 'e', ignoreCase: true)), + default => false, + }; + } + /** * Execute an inline check against a value. * @@ -158,7 +269,7 @@ protected function executeInline(InlineCheck $check, mixed $value, string $attri CheckType::Ipv6 => is_string($value) && $this->isValidIpv6($value), CheckType::Uuid => is_string($value) && $this->isValidUuid($value), CheckType::Ulid => is_string($value) && $this->isValidUlid($value), - CheckType::Json => $this->executeInlineJson($value), + CheckType::Json => $this->validateJson($attribute, $value), CheckType::Ascii => is_string($value) && Str::isAscii($value), CheckType::HexColor => is_string($value) && preg_match('/^#(?:(?:[0-9a-f]{3}){1,2}|(?:[0-9a-f]{4}){1,2})$/i', $value) === 1, @@ -177,15 +288,33 @@ protected function executeInline(InlineCheck $check, mixed $value, string $attri CheckType::Lowercase => is_string($value) && Str::lower($value) === $value, CheckType::Uppercase => is_string($value) && Str::upper($value) === $value, - CheckType::SizeMin => $this->compareSize($attribute, $value, $check->param['n'], '>=', $check->param['mode']), - CheckType::SizeMax => $this->compareSize($attribute, $value, $check->param['n'], '<=', $check->param['mode']), - CheckType::SizeExact => $this->compareSize($attribute, $value, $check->param['n'], '==', $check->param['mode']), + CheckType::SizeMin => $this->compareSize( + $attribute, + $value, + $check->param['threshold'], + '>=', + $check->param['numeric'], + ), + CheckType::SizeMax => $this->compareSize( + $attribute, + $value, + $check->param['threshold'], + '<=', + $check->param['numeric'], + ), + CheckType::SizeExact => $this->compareSize( + $attribute, + $value, + $check->param['threshold'], + '==', + $check->param['numeric'], + ), CheckType::SizeBetween => $this->compareSizeBetween( $attribute, $value, - $check->param['min'], - $check->param['max'], - $check->param['mode'] + $check->param['minimum'], + $check->param['maximum'], + $check->param['numeric'], ), CheckType::Digits => (is_string($value) || is_numeric($value)) && ! preg_match('/[^0-9]/', $s = (string) $value) @@ -228,55 +357,35 @@ protected function executeInline(InlineCheck $check, mixed $value, string $attri }; } - /** - * Inline JSON validation matching validateJson() behavior. - * - * Checks for array/null and non-stringable objects before validation. - */ - private function executeInlineJson(mixed $value): bool - { - if (is_array($value) || is_null($value)) { - return false; - } - - if (! is_scalar($value) && ! method_exists($value, '__toString')) { - return false; - } - - return Json::validate((string) $value); - } - /** * Compare a value's size against a threshold. * - * For String/Array modes with numeric thresholds, uses native comparison - * (mb_strlen and count always return int). Falls back to BigNumber only - * for Numeric/File modes. + * @param array{raw: string, integer: ?int} $threshold */ - private function compareSize(string $attribute, mixed $value, string $target, string $operator, SizeMode $mode): bool - { - $target = $this->trim($target); - - if (($mode === SizeMode::String || $mode === SizeMode::Array) - && $target !== '' && is_numeric($target) - ) { - $size = $this->sizeOf($value, $mode); - $numericTarget = (float) $target; - + private function compareSize( + string $attribute, + mixed $value, + array $threshold, + string $operator, + bool $numeric, + ): bool { + $size = $this->sizeOf($attribute, $value, $numeric); + + if ($this->usesNativeSizeComparison($value, $numeric) && $threshold['integer'] !== null) { return match ($operator) { - '>=' => $size >= $numericTarget, - '<=' => $size <= $numericTarget, - '==' => (float) $size === $numericTarget, + '>=' => $size >= $threshold['integer'], + '<=' => $size <= $threshold['integer'], + '==' => $size === $threshold['integer'], default => throw new InvalidArgumentException("Unsupported size comparison operator: {$operator}"), }; } - $size = BigNumber::of((string) $this->sizeOfWithExponentCheck($attribute, $value, $mode)); + $size = BigNumber::of((string) $size); return match ($operator) { - '>=' => $size->isGreaterThanOrEqualTo($target), - '<=' => $size->isLessThanOrEqualTo($target), - '==' => $size->isEqualTo($target), + '>=' => $size->isGreaterThanOrEqualTo($threshold['raw']), + '<=' => $size->isLessThanOrEqualTo($threshold['raw']), + '==' => $size->isEqualTo($threshold['raw']), default => throw new InvalidArgumentException("Unsupported size comparison operator: {$operator}"), }; } @@ -284,41 +393,37 @@ private function compareSize(string $attribute, mixed $value, string $target, st /** * Compare a value's size against a min/max range. * - * Native fast path for String/Array modes with numeric thresholds. + * @param array{raw: string, integer: ?int} $minimum + * @param array{raw: string, integer: ?int} $maximum */ - private function compareSizeBetween(string $attribute, mixed $value, string $min, string $max, SizeMode $mode): bool - { - $min = $this->trim($min); - $max = $this->trim($max); - - if (($mode === SizeMode::String || $mode === SizeMode::Array) - && $min !== '' && is_numeric($min) - && $max !== '' && is_numeric($max) + private function compareSizeBetween( + string $attribute, + mixed $value, + array $minimum, + array $maximum, + bool $numeric, + ): bool { + $size = $this->sizeOf($attribute, $value, $numeric); + + if ($this->usesNativeSizeComparison($value, $numeric) + && $minimum['integer'] !== null + && $maximum['integer'] !== null ) { - $size = $this->sizeOf($value, $mode); - - return $size >= (float) $min && $size <= (float) $max; + return $size >= $minimum['integer'] && $size <= $maximum['integer']; } - $size = BigNumber::of((string) $this->sizeOfWithExponentCheck($attribute, $value, $mode)); + $size = BigNumber::of((string) $size); - return $size->isGreaterThanOrEqualTo($min) && $size->isLessThanOrEqualTo($max); + return $size->isGreaterThanOrEqualTo($minimum['raw']) + && $size->isLessThanOrEqualTo($maximum['raw']); } /** - * Compute a value's size with exponent-range checking for numeric modes. - * - * For numeric-mode values, runs ensureExponentWithinAllowedRange() to - * match getSize()'s behavior. This preserves the MathException for - * out-of-range exponents and any custom exponent-range callbacks. + * Determine if a size comparison can use native integer arithmetic. */ - private function sizeOfWithExponentCheck(string $attribute, mixed $value, SizeMode $mode): float|int|string + private function usesNativeSizeComparison(mixed $value, bool $numeric): bool { - if ($mode === SizeMode::Numeric && is_numeric($value)) { - return $this->ensureExponentWithinAllowedRange($attribute, $this->trim($value)); - } - - return $this->sizeOf($value, $mode); + return ! ($numeric && is_numeric($value)) && ! $value instanceof SplFileInfo; } /** diff --git a/src/validation/src/PrecomputedPresenceVerifier.php b/src/validation/src/PrecomputedPresenceVerifier.php index 1ecfcc2585..3eddf6a314 100644 --- a/src/validation/src/PrecomputedPresenceVerifier.php +++ b/src/validation/src/PrecomputedPresenceVerifier.php @@ -4,123 +4,200 @@ namespace Hypervel\Validation; -use Stringable; +use Closure; /** - * Presence verifier that returns pre-computed results from batched queries. + * Return database-proven presence facts from batched queries. * - * Used by BatchDatabaseChecker to replace per-item DB queries with lookup-set - * checks. Original Exists/Unique rule objects stay in place, so the full - * message resolution pipeline works unchanged. - * - * Results are scoped by table+column. Falls back to the provided verifier - * for lookups that weren't pre-computed (rules with closure callbacks). + * Lookups and fallback memoization are scoped to one validation execution and + * keyed by the complete database query shape. Unknown probes delegate to the + * original verifier, preserving ordinary validation semantics. */ final class PrecomputedPresenceVerifier implements DatabasePresenceVerifierInterface { - /** @var array> Keyed by "table:column", values are string-cast flip maps. */ + /** + * @var array, + * knownPresent: array, + * provenAbsent: array, + * stageOneSingleChunk: bool + * }> + */ private array $lookups = []; + /** @var array> */ + private array $fallbackCounts = []; + + private ?string $connection = null; + public function __construct( - private readonly ?PresenceVerifierInterface $fallback = null, + private readonly DatabasePresenceVerifierInterface $fallback, ) { } /** - * Register pre-computed values for a table+column pair. - * - * Values are cast to strings and stored as a flip map for O(1) isset() - * lookups. String cast matches database implicit type coercion behavior. - * - * @param array $values values that exist in the database + * Build a key for the complete database query shape. */ - public function addLookup(string $table, string $column, array $values): void - { - $map = []; - - foreach ($values as $value) { - if (($normalized = self::normalizeValue($value)) !== null) { - $map[$normalized] = true; + public static function lookupKey( + ?string $connection, + string $collection, + string $column, + int|string|null $excludeId = null, + ?string $idColumn = null, + array $extra = [], + ): ?string { + $conditions = []; + + foreach ($extra as $key => $value) { + if ($value instanceof Closure + || (! is_scalar($value) && $value !== null) + ) { + return null; } + + $conditions[] = [(string) $key, (string) $value]; } - $this->lookups[$table . ':' . $column] = $map; + $shape = [ + 'connection' => $connection, + 'collection' => $collection, + 'column' => $column, + 'conditions' => $conditions, + ]; + + if ($excludeId !== null && $excludeId !== 'NULL') { + $shape['exclusion'] = [$idColumn ?: 'id', $excludeId]; + } + + return serialize($shape); } /** - * Count the number of objects in a collection having the given value. + * Register database-proven facts for one query shape. * - * Returns 1 if the value exists in the precomputed lookup, 0 otherwise. - * Falls back to the original verifier when no lookup was registered for - * this table:column pair. + * @param array $exactHits + * @param array $knownPresent + * @param array $provenAbsent + */ + public function addLookup( + string $lookupKey, + array $exactHits, + array $knownPresent, + array $provenAbsent, + bool $stageOneSingleChunk, + ): void { + $this->lookups[$lookupKey] = [ + 'exactHits' => $exactHits, + 'knownPresent' => $knownPresent, + 'provenAbsent' => $provenAbsent, + 'stageOneSingleChunk' => $stageOneSingleChunk, + ]; + } + + /** + * Count the number of objects in a collection having the given value. * * @param array $extra */ public function getCount(string $collection, string $column, mixed $value, int|string|null $excludeId = null, ?string $idColumn = null, array $extra = []): int { - $key = $collection . ':' . $column; + $lookupKey = self::lookupKey( + $this->connection, + $collection, + $column, + $excludeId, + $idColumn, + $extra, + ); + + if ($lookupKey === null || ! isset($this->lookups[$lookupKey])) { + return $this->fallback->getCount($collection, $column, $value, $excludeId, $idColumn, $extra); + } + + $bindingKey = self::bindingKey($value); - if (! isset($this->lookups[$key])) { - return $this->fallback?->getCount($collection, $column, $value, $excludeId, $idColumn, $extra) ?? 0; + if ($bindingKey === null) { + return $this->fallback->getCount($collection, $column, $value, $excludeId, $idColumn, $extra); } - $normalized = self::normalizeValue($value); + $lookup = $this->lookups[$lookupKey]; - if ($normalized === null) { - return $this->fallback?->getCount($collection, $column, $value, $excludeId, $idColumn, $extra) ?? 0; + if (isset($lookup['exactHits'][$bindingKey]) || isset($lookup['knownPresent'][$bindingKey])) { + return 1; } - return isset($this->lookups[$key][$normalized]) ? 1 : 0; + if (isset($lookup['provenAbsent'][$bindingKey])) { + return 0; + } + + return $this->fallbackCounts[$lookupKey][$bindingKey] + ??= $this->fallback->getCount($collection, $column, $value, $excludeId, $idColumn, $extra); } /** * Count the number of objects in a collection with the given values. * - * Uses distinct counting to match DatabasePresenceVerifier's - * ->distinct()->count($column) semantics. Duplicate input values - * are counted only once. - * * @param array $values * @param array $extra */ public function getMultiCount(string $collection, string $column, array $values, array $extra = []): int { - $key = $collection . ':' . $column; + $lookupKey = self::lookupKey($this->connection, $collection, $column, extra: $extra); - if (! isset($this->lookups[$key])) { - return $this->fallback?->getMultiCount($collection, $column, $values, $extra) ?? 0; + if ($lookupKey === null || ! isset($this->lookups[$lookupKey])) { + return $this->fallback->getMultiCount($collection, $column, $values, $extra); } - $count = 0; - $lookup = $this->lookups[$key]; - $seen = []; + $bindingValues = []; foreach ($values as $value) { - $normalized = self::normalizeValue($value); + $bindingKey = self::bindingKey($value); - if ($normalized === null) { - return $this->fallback?->getMultiCount($collection, $column, $values, $extra) ?? 0; + if ($bindingKey === null) { + return $this->fallback->getMultiCount($collection, $column, $values, $extra); } - if (! isset($seen[$normalized]) && isset($lookup[$normalized])) { - ++$count; - $seen[$normalized] = true; + $bindingValues[$bindingKey] = substr($bindingKey, 1); + } + + $lookup = $this->lookups[$lookupKey]; + $distinctValueCount = count(array_unique($bindingValues, SORT_STRING)); + $presentValues = []; + + foreach ($bindingValues as $bindingKey => $normalizedValue) { + if (isset($lookup['exactHits'][$bindingKey])) { + if (! $lookup['stageOneSingleChunk']) { + return $this->fallback->getMultiCount($collection, $column, $values, $extra); + } + + $presentValues[$normalizedValue] = true; + continue; + } + + if (isset($lookup['knownPresent'][$bindingKey])) { + if ($distinctValueCount !== 1) { + return $this->fallback->getMultiCount($collection, $column, $values, $extra); + } + + $presentValues[$normalizedValue] = true; + continue; + } + + if (! isset($lookup['provenAbsent'][$bindingKey])) { + return $this->fallback->getMultiCount($collection, $column, $values, $extra); } } - return $count; + return count($presentValues); } /** * Set the connection to be used. - * - * Delegates to the fallback verifier if it supports connections. */ public function setConnection(?string $connection): void { - if ($this->fallback instanceof DatabasePresenceVerifierInterface) { - $this->fallback->setConnection($connection); - } + $this->connection = $connection; + $this->fallback->setConnection($connection); } /** @@ -132,11 +209,26 @@ public function hasLookups(): bool } /** - * Normalize a supported presence value. + * Build a key for the PDO binding identity of a supported presence value. + * + * The one-character prefix preserves integer/string PDO binding identity + * and prevents PHP from coercing numeric-string array keys to integers. + */ + public static function bindingKey(mixed $value): ?string + { + $normalizedValue = self::normalizeValue($value); + + return $normalizedValue === null + ? null + : (is_int($value) ? 'i' : 's') . $normalizedValue; + } + + /** + * Normalize a supported presence value for lookup comparisons. */ - private static function normalizeValue(mixed $value): ?string + public static function normalizeValue(mixed $value): ?string { - if (! is_scalar($value) && ! $value instanceof Stringable) { + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { return null; } diff --git a/src/validation/src/RuleCompiler.php b/src/validation/src/RuleCompiler.php index 1207d9e644..eece5c1068 100644 --- a/src/validation/src/RuleCompiler.php +++ b/src/validation/src/RuleCompiler.php @@ -4,19 +4,16 @@ namespace Hypervel\Validation; -use Hypervel\Contracts\Validation\ImplicitRule; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Validation\Enums\CheckType; -use Hypervel\Validation\Enums\SizeMode; -use Stringable; /** * Compile pipe-string or array rules into an AttributePlan. * * Each rule part becomes either an InlineCheck (fast, match-dispatched) or a * DelegatedCheck (calls existing validate*() methods). The compiler resolves - * sibling context (size mode, date format, array presence) to bake compile-time - * decisions into check params. + * sibling context (numeric semantics, date format, array presence) to bake + * compile-time decisions into check params. */ final class RuleCompiler { @@ -26,16 +23,30 @@ final class RuleCompiler * Used for the base Validator class. Subclasses use compileAllDelegated(). * * @param list $rules As produced by ValidationRuleParser::explode() + * @param list $numericRules Rules which activate numeric size semantics */ - public static function compile(array $rules): AttributePlan + public static function compile(array $rules, array $numericRules): AttributePlan { $plan = new AttributePlan; + $parsedRules = array_map( + static fn (mixed $rule): array => ValidationRuleParser::parse($rule), + $rules, + ); - $context = self::collectContext($rules); - $plan->sizeMode = $context['sizeMode']; + foreach ($parsedRules as [, $parameters]) { + if (array_any($parameters, static fn (mixed $parameter): bool => ! is_scalar($parameter))) { + foreach ($rules as $index => $rule) { + self::compileRuleDelegated($rule, $plan, $parsedRules[$index]); + } - foreach ($rules as $rule) { - self::compileRule($rule, $plan, $context); + return $plan; + } + } + + $context = self::collectContext($parsedRules, $numericRules); + + foreach ($rules as $index => $rule) { + self::compileRule($rule, $parsedRules[$index], $plan, $context); } return $plan; @@ -44,9 +55,8 @@ public static function compile(array $rules): AttributePlan /** * Compile all rules as DelegatedCheck (no inlining). * - * Used for Validator subclasses which may override validate*() methods. - * Shares the same flag pre-resolution so the execution loop's attribute-level - * logic (sometimes, excluded) still works. + * Used for Validator subclasses. Retains meta flags for attribute-level + * gating while keeping every declared rule available to delegated execution. * * @param list $rules As produced by ValidationRuleParser::explode() */ @@ -54,9 +64,6 @@ public static function compileAllDelegated(array $rules): AttributePlan { $plan = new AttributePlan; - $context = self::collectContext($rules); - $plan->sizeMode = $context['sizeMode']; - foreach ($rules as $rule) { self::compileRuleDelegated($rule, $plan); } @@ -67,32 +74,27 @@ public static function compileAllDelegated(array $rules): AttributePlan /** * Pre-scan all rule parts to collect compile-time context. * - * @return array{sizeMode: ?SizeMode, dateFormat: ?string, hasSiblingArrayRule: bool} + * @param list}> $parsedRules + * @param list $numericRules + * @return array{numeric: bool, dateFormat: ?string, hasSiblingArrayRule: bool} */ - private static function collectContext(array $rules): array + private static function collectContext(array $parsedRules, array $numericRules): array { - /** @var list $modes */ - $modes = []; + $numeric = false; $dateFormat = null; $hasSiblingArrayRule = false; - foreach ($rules as $rule) { - [$parsedName, $parsedParams] = ValidationRuleParser::parse($rule); - + foreach ($parsedRules as [$parsedName, $parsedParameters]) { if (! is_string($parsedName)) { continue; } - if (($mode = self::resolveSizeMode($parsedName)) !== null) { - $modes[] = $mode; + if (in_array($parsedName, $numericRules, true)) { + $numeric = true; } - if ($dateFormat === null && $parsedName === 'DateFormat') { - $format = $parsedParams[0] ?? null; - - if (is_scalar($format) || $format instanceof Stringable) { - $dateFormat = (string) $format; - } + if ($dateFormat === null && $parsedName === 'DateFormat' && isset($parsedParameters[0])) { + $dateFormat = (string) $parsedParameters[0]; } if ($parsedName === 'Array') { @@ -100,96 +102,42 @@ private static function collectContext(array $rules): array } } - $uniqueModes = array_values(array_unique($modes, SORT_REGULAR)); - $sizeMode = count($uniqueModes) === 1 ? $uniqueModes[0] : null; - return [ - 'sizeMode' => $sizeMode, + 'numeric' => $numeric, 'dateFormat' => $dateFormat, 'hasSiblingArrayRule' => $hasSiblingArrayRule, ]; } - /** - * Map a parsed rule name to the SizeMode it implies. - * - * Returns null for rules that don't imply a size mode. - */ - private static function resolveSizeMode(string $parsedName): ?SizeMode - { - return match ($parsedName) { - 'String' => SizeMode::String, - 'Numeric', 'Integer' => SizeMode::Numeric, - 'Array' => SizeMode::Array, - 'File', 'Image' => SizeMode::File, - default => null, - }; - } - /** * Compile a single rule into the plan, attempting to inline where possible. * - * Handles four input forms: RuleContract objects, Exists/Unique Stringable - * objects, raw non-string values, and string rule tokens. String rules are - * parsed, flags are resolved, and eligible rules are compiled as InlineCheck. + * RuleContract objects remain intact. Other rules are parsed, flags are + * resolved, and eligible string rules are compiled as InlineCheck. * Everything else becomes a DelegatedCheck. * - * @param array{sizeMode: ?SizeMode, dateFormat: ?string, hasSiblingArrayRule: bool} $context + * @param array{0: mixed, 1: array} $parsedRule + * @param array{numeric: bool, dateFormat: ?string, hasSiblingArrayRule: bool} $context */ - private static function compileRule(mixed $rule, AttributePlan $plan, array $context): void + private static function compileRule(mixed $rule, array $parsedRule, AttributePlan $plan, array $context): void { if ($rule instanceof RuleContract) { - if ($rule instanceof ImplicitRule) { - $plan->hasImplicitRule = true; - } $plan->checks[] = new DelegatedCheck( ruleName: '', parameters: [], - ruleObject: $rule, - originalRule: $rule, - ); - return; - } - - if ($rule instanceof Rules\Exists || $rule instanceof Rules\Unique) { - [$ruleName, $parameters] = ValidationRuleParser::parse($rule); - $plan->checks[] = new DelegatedCheck( - ruleName: $ruleName, - parameters: $parameters, - ruleObject: $rule, - originalRule: $rule, - ); - return; - } - - if (! is_string($rule)) { - [$parsedName, $parsedParams] = ValidationRuleParser::parse($rule); - if (! is_string($parsedName) || $parsedName === '') { - return; - } - $plan->checks[] = new DelegatedCheck( - ruleName: $parsedName, - parameters: $parsedParams, originalRule: $rule, ); return; } - [$ruleName, $parameters] = ValidationRuleParser::parse($rule); + [$ruleName, $parameters] = $parsedRule; - if ($ruleName === '') { + if (! is_string($ruleName) || $ruleName === '') { return; } - // required sets a flag AND falls through to produce a DelegatedCheck. - // It is a real validation rule whose validateRequired() can fail. - if ($ruleName === 'Required') { - $plan->required = true; - $plan->hasImplicitRule = true; - } - - // nullable/bail/sometimes are pure meta-flags — their validate*() methods - // are no-ops returning true, so they don't need checks. + // The exact base plan needs nullable/bail/sometimes only as meta-flags. + // compileRuleDelegated() also emits them so subclass hooks still run. if ($ruleName === 'Nullable') { $plan->nullable = true; return; @@ -209,10 +157,6 @@ private static function compileRule(mixed $rule, AttributePlan $plan, array $con return; } - if (self::isImplicitRule($ruleName)) { - $plan->hasImplicitRule = true; - } - $plan->checks[] = new DelegatedCheck( ruleName: $ruleName, parameters: $parameters, @@ -223,74 +167,34 @@ private static function compileRule(mixed $rule, AttributePlan $plan, array $con /** * Compile a single rule as DelegatedCheck only (no inlining). * - * Used by compileAllDelegated() for Validator subclasses. Handles the - * same input forms and flag resolution as compileRule() but skips the - * tryInline() step — everything becomes a DelegatedCheck. + * Handles the same input forms and flag resolution as compileRule() but + * skips tryInline() so every declared rule becomes a DelegatedCheck. + * + * @param null|array{0: mixed, 1: array} $parsedRule */ - private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): void + private static function compileRuleDelegated(mixed $rule, AttributePlan $plan, ?array $parsedRule = null): void { if ($rule instanceof RuleContract) { - if ($rule instanceof ImplicitRule) { - $plan->hasImplicitRule = true; - } $plan->checks[] = new DelegatedCheck( ruleName: '', parameters: [], - ruleObject: $rule, - originalRule: $rule, - ); - return; - } - - if ($rule instanceof Rules\Exists || $rule instanceof Rules\Unique) { - [$ruleName, $parameters] = ValidationRuleParser::parse($rule); - $plan->checks[] = new DelegatedCheck( - ruleName: $ruleName, - parameters: $parameters, - ruleObject: $rule, - originalRule: $rule, - ); - return; - } - - if (! is_string($rule)) { - [$parsedName, $parsedParams] = ValidationRuleParser::parse($rule); - if (! is_string($parsedName) || $parsedName === '') { - return; - } - $plan->checks[] = new DelegatedCheck( - ruleName: $parsedName, - parameters: $parsedParams, originalRule: $rule, ); return; } - [$ruleName, $parameters] = ValidationRuleParser::parse($rule); + [$ruleName, $parameters] = $parsedRule ?? ValidationRuleParser::parse($rule); - if ($ruleName === '') { + if (! is_string($ruleName) || $ruleName === '') { return; } - if ($ruleName === 'Required') { - $plan->required = true; - $plan->hasImplicitRule = true; - } if ($ruleName === 'Nullable') { $plan->nullable = true; - return; - } - if ($ruleName === 'Bail') { + } elseif ($ruleName === 'Bail') { $plan->bail = true; - return; - } - if ($ruleName === 'Sometimes') { + } elseif ($ruleName === 'Sometimes') { $plan->sometimes = true; - return; - } - - if (self::isImplicitRule($ruleName)) { - $plan->hasImplicitRule = true; } $plan->checks[] = new DelegatedCheck( @@ -305,7 +209,7 @@ private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): * * Returns null if the rule is not inline-eligible (it will become a DelegatedCheck). * - * @param array{sizeMode: ?SizeMode, dateFormat: ?string, hasSiblingArrayRule: bool} $context + * @param array{numeric: bool, dateFormat: ?string, hasSiblingArrayRule: bool} $context */ private static function tryInline(string $ruleName, array $parameters, array $context): ?InlineCheck { @@ -377,9 +281,7 @@ private static function tryInline(string $ruleName, array $parameters, array $co : new InlineCheck( CheckType::In, array_map( - static fn (mixed $parameter): mixed => is_scalar($parameter) || $parameter instanceof Stringable - ? (string) $parameter - : $parameter, + static fn (mixed $parameter): string => (string) $parameter, $parameters, ), parameters: $parameters, @@ -389,9 +291,7 @@ private static function tryInline(string $ruleName, array $parameters, array $co : new InlineCheck( CheckType::NotIn, array_map( - static fn (mixed $parameter): mixed => is_scalar($parameter) || $parameter instanceof Stringable - ? (string) $parameter - : $parameter, + static fn (mixed $parameter): string => (string) $parameter, $parameters, ), parameters: $parameters, @@ -418,20 +318,20 @@ private static function tryInline(string $ruleName, array $parameters, array $co /** * Try to inline a min/max/size rule as a size check. * - * Returns null when there's no parameter, the parameter isn't numeric, - * or the size mode couldn't be resolved (ambiguous sibling type rules). - * Thresholds are stored as raw numeric strings so BigNumber comparison - * preserves decimal precision. + * Returns null when there's no numeric parameter. */ private static function tryInlineSize(CheckType $type, array $parameters, array $context): ?InlineCheck { - if (! isset($parameters[0]) || ! is_numeric($parameters[0]) || $context['sizeMode'] === null) { + if (! isset($parameters[0]) || ! is_numeric($parameters[0])) { return null; } return new InlineCheck( $type, - ['n' => $parameters[0], 'mode' => $context['sizeMode']], + [ + 'numeric' => $context['numeric'], + 'threshold' => self::compileSizeThreshold($parameters[0]), + ], parameters: $parameters, ); } @@ -439,26 +339,44 @@ private static function tryInlineSize(CheckType $type, array $parameters, array /** * Try to inline a between rule as a size-between check. * - * Returns null when the parameter count is wrong, bounds aren't numeric, - * or the size mode couldn't be resolved. + * Returns null when the parameter count is wrong or bounds aren't numeric. */ private static function tryInlineSizeBetween(array $parameters, array $context): ?InlineCheck { if (count($parameters) !== 2 || ! is_numeric($parameters[0]) || ! is_numeric($parameters[1]) - || $context['sizeMode'] === null ) { return null; } return new InlineCheck( CheckType::SizeBetween, - ['min' => $parameters[0], 'max' => $parameters[1], 'mode' => $context['sizeMode']], + [ + 'numeric' => $context['numeric'], + 'minimum' => self::compileSizeThreshold($parameters[0]), + 'maximum' => self::compileSizeThreshold($parameters[1]), + ], parameters: $parameters, ); } + /** + * Normalize and classify a size threshold for execution. + * + * @return array{raw: string, integer: ?int} + */ + private static function compileSizeThreshold(mixed $threshold): array + { + $rawThreshold = trim((string) $threshold); + $integerThreshold = filter_var($rawThreshold, FILTER_VALIDATE_INT); + + return [ + 'raw' => $rawThreshold, + 'integer' => $integerThreshold === false ? null : $integerThreshold, + ]; + } + /** * Determine if the parameters are exact integer strings. */ @@ -480,7 +398,7 @@ private static function hasExactIntegerParameters(array $parameters, int $count) * those must go through DelegatedCheck where compareDates() resolves the * referenced attribute's value. * - * @param array{sizeMode: ?SizeMode, dateFormat: ?string, hasSiblingArrayRule: bool} $context + * @param array{numeric: bool, dateFormat: ?string, hasSiblingArrayRule: bool} $context */ private static function tryInlineDate(CheckType $type, array $parameters, array $context): ?InlineCheck { @@ -498,24 +416,4 @@ private static function tryInlineDate(CheckType $type, array $parameters, array parameters: $parameters, ); } - - /** - * Determine if a rule name identifies an implicit rule. - * - * Implicit rules run even when the attribute is absent or empty. This - * list mirrors Validator::$implicitRules and is used to set the - * hasImplicitRule flag on the compiled plan. - */ - private static function isImplicitRule(string $ruleName): bool - { - return in_array($ruleName, [ - 'Accepted', 'AcceptedIf', 'Declined', 'DeclinedIf', - 'Filled', - 'Missing', 'MissingIf', 'MissingUnless', 'MissingWith', 'MissingWithAll', - 'Present', 'PresentIf', 'PresentUnless', 'PresentWith', 'PresentWithAll', - 'Required', 'RequiredIf', 'RequiredIfAccepted', 'RequiredIfDeclined', - 'RequiredUnless', 'RequiredWith', 'RequiredWithAll', - 'RequiredWithout', 'RequiredWithoutAll', - ], true); - } } diff --git a/src/validation/src/Rules/DatabaseRule.php b/src/validation/src/Rules/DatabaseRule.php index 2dda68071d..473d83af58 100644 --- a/src/validation/src/Rules/DatabaseRule.php +++ b/src/validation/src/Rules/DatabaseRule.php @@ -85,7 +85,7 @@ public function where(Closure|string $column, mixed $value = null): static return $this->whereNull($column); } - $value = enum_value($value); + $value = $this->normalizeWhereValue($value); $this->wheres[] = compact('column', 'value'); @@ -101,7 +101,7 @@ public function whereNot(string $column, mixed $value): static return $this->whereNotIn($column, $value); } - $value = enum_value($value); + $value = $this->normalizeWhereValue($value); return $this->where($column, '!' . $value); } @@ -143,7 +143,7 @@ public function whereNotIn(string $column, array|Arrayable|BackedEnum $values): } /** - * Ignore soft deleted models during the existence check.s. + * Ignore soft deleted models during the existence checks. */ public function withoutTrashed(string $deletedAtColumn = 'deleted_at'): static { @@ -181,18 +181,13 @@ public function queryCallbacks(): array } /** - * Get the database presence rule metadata. - * - * @return array{table: string, column: string, wheres: array>, using: array} + * Normalize a where value for string serialization. */ - public function presenceMetadata(): array + protected function normalizeWhereValue(mixed $value): mixed { - return [ - 'table' => $this->table, - 'column' => $this->column, - 'wheres' => $this->wheres, - 'using' => $this->using, - ]; + $value = enum_value($value); + + return is_bool($value) ? (int) $value : $value; } /** diff --git a/src/validation/src/Rules/Exists.php b/src/validation/src/Rules/Exists.php index 182e885fd0..f37f41753b 100644 --- a/src/validation/src/Rules/Exists.php +++ b/src/validation/src/Rules/Exists.php @@ -5,10 +5,9 @@ namespace Hypervel\Validation\Rules; use Hypervel\Support\Traits\Conditionable; -use Hypervel\Validation\Contracts\DatabasePresenceRule; use Stringable; -class Exists implements DatabasePresenceRule, Stringable +class Exists implements Stringable { use Conditionable; use DatabaseRule; diff --git a/src/validation/src/Rules/Unique.php b/src/validation/src/Rules/Unique.php index 4e36897621..3f897fb673 100644 --- a/src/validation/src/Rules/Unique.php +++ b/src/validation/src/Rules/Unique.php @@ -6,10 +6,9 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Traits\Conditionable; -use Hypervel\Validation\Contracts\DatabasePresenceRule; use Stringable; -class Unique implements DatabasePresenceRule, Stringable +class Unique implements Stringable { use Conditionable; use DatabaseRule; @@ -50,23 +49,6 @@ public function ignoreModel(Model $model, ?string $idColumn = null): static return $this; } - /** - * Get the database presence rule metadata. - * - * @return array{table: string, column: string, wheres: array>, using: array, ignore: mixed, idColumn: string} - */ - public function presenceMetadata(): array - { - return [ - 'table' => $this->table, - 'column' => $this->column, - 'wheres' => $this->wheres, - 'using' => $this->using, - 'ignore' => $this->ignore, - 'idColumn' => $this->idColumn, - ]; - } - /** * Convert the rule to a validation string. */ @@ -76,7 +58,7 @@ public function __toString(): string 'unique:%s,%s,%s,%s,%s', $this->table, $this->column, - $this->ignore ? '"' . addslashes((string) $this->ignore) . '"' : 'NULL', + $this->ignore !== null ? '"' . addslashes((string) $this->ignore) . '"' : 'NULL', $this->idColumn, $this->formatWheres() ), ','); diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php index eff41e5e21..602e037ae8 100644 --- a/src/validation/src/ValidationRuleParser.php +++ b/src/validation/src/ValidationRuleParser.php @@ -138,7 +138,7 @@ protected function prepareRule(mixed $rule, string $attribute): mixed )->rules[$attribute]; } - return $rule; + return (string) $rule; } /** diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 03451cb8be..723ee241fa 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -23,9 +23,11 @@ use Hypervel\Support\StrCache; use Hypervel\Support\ValidatedInput; use InvalidArgumentException; +use LogicException; use RuntimeException; use stdClass; use Throwable; +use ValueError; class Validator implements ValidatorContract { @@ -53,6 +55,16 @@ class Validator implements ValidatorContract */ protected array $excludeAttributes = []; + /** + * Active exclusions for exact-base compiled execution. + * + * Subclasses keep Laravel's protected list because their passes() methods + * may reset that list directly. + * + * @var array + */ + private array $activeExclusions = []; + /** * The message bag instance. */ @@ -111,22 +123,19 @@ class Validator implements ValidatorContract protected array $compiledPlans = []; /** - * The original presence verifier, saved during batched DB checks. + * Parsed presence-rule tables for the current passes() invocation. * - * Restored after passes() completes so subsequent validate() calls - * on the same instance aren't polluted by the precomputed verifier. + * @var array */ - protected ?PresenceVerifierInterface $originalPresenceVerifier = null; + private array $parsedTables = []; /** - * Attributes pre-excluded by the exclude_unless/exclude_if pre-pass. - * - * Stored separately from the compiled plans so cached plans remain - * immutable and shareable across requests without cloning. + * The original presence verifier, saved during batched DB checks. * - * @var array + * Restored after passes() completes so subsequent validate() calls + * on the same instance aren't polluted by the precomputed verifier. */ - protected array $preExcludedAttributes = []; + protected ?PresenceVerifierInterface $originalPresenceVerifier = null; /** * All of the registered "after" callbacks. @@ -435,44 +444,41 @@ public function passes(): bool { $this->messages = new MessageBag; [$this->distinctValues, $this->failedRules, $this->excludeAttributes] = [[], [], []]; + $this->activeExclusions = []; $this->originalPresenceVerifier = null; - $this->preExcludedAttributes = []; + $this->parsedTables = []; $this->compiledPlans = $this->compileRules(); + $preExcludedAttributes = []; - // Exclude pre-evaluation reads $this->data before execution. Only safe - // when no code path can mutate data during execution (no custom - // extensions, no ValidatorAwareRule objects, no validator subclasses). - $canOptimize = static::class === self::class - && $this->extensions === [] - && ! $this->compiledPlansContainValidatorAwareRules(); - - if ($canOptimize) { - $this->preEvaluateExclusions(); - - if ($this->preExcludedAttributes !== []) { - $this->compiledPlans = array_filter( - $this->compiledPlans, - fn (string $attribute): bool => ! $this->isPreExcludedOrDescendant($attribute), - ARRAY_FILTER_USE_KEY, + if (static::class === self::class) { + [$preExcludedAttributes, $unresolvedExclusionAttributes] = $this->preEvaluateExclusions(); + + $activeVerifier = $this->presenceVerifier; + // A failed speculative PostgreSQL query aborts the caller's transaction + // even when caught, so global early-stop must use ordered presence queries. + if ($activeVerifier !== null + && $activeVerifier::class === DatabasePresenceVerifier::class + && ! $this->stopOnFirstFailure + ) { + $this->maybeBatchDatabaseChecks( + $activeVerifier, + $preExcludedAttributes, + $unresolvedExclusionAttributes, ); } } - $activeVerifier = $this->presenceVerifier; - if ($canOptimize - && $activeVerifier !== null - && $activeVerifier::class === DatabasePresenceVerifier::class - ) { - $this->maybeBatchDatabaseChecks($activeVerifier); - } - try { - $this->executeCompiledPlans($this->compiledPlans); + if (static::class === self::class) { + $this->executeCompiledPlans($this->compiledPlans, $preExcludedAttributes); + } else { + $this->executeDelegatedPlans($this->compiledPlans); + } foreach ($this->rules as $attribute => $rules) { $attribute = (string) $attribute; - if ($this->isPreExcludedOrDescendant($attribute) || $this->shouldBeExcluded($attribute)) { + if ($this->shouldBeExcluded($attribute)) { $this->removeAttribute($attribute); } } @@ -520,7 +526,7 @@ protected function compileRules(): array } $plan = $isBaseValidator - ? RuleCompiler::compile($rules) + ? RuleCompiler::compile($rules, $this->defaultNumericRules) : RuleCompiler::compileAllDelegated($rules); if ($isBaseValidator) { @@ -534,18 +540,42 @@ protected function compileRules(): array } /** - * Determine if any compiled plan contains a ValidatorAwareRule. - * - * ValidatorAwareRule implementations receive the live validator via - * setValidator($this) and can mutate $this->data via setValue()/setData(). - * This makes pre-evaluation of exclude conditions unsafe because the - * pre-pass reads data before execution. + * Determine if any compiled check can mutate this validator's data. */ - protected function compiledPlansContainValidatorAwareRules(): bool + protected function compiledPlansUseDataMutatingRules(): bool { foreach ($this->compiledPlans as $plan) { foreach ($plan->checks as $check) { - if ($check instanceof DelegatedCheck && $check->ruleObject instanceof ValidatorAwareRule) { + if (! $check instanceof DelegatedCheck) { + continue; + } + + $originalRule = $check->originalRule; + + if ($originalRule instanceof ClosureValidationRule) { + return true; + } + + if ($originalRule instanceof InvokableValidationRule) { + if ($originalRule->invokable() instanceof ValidatorAwareRule) { + return true; + } + + continue; + } + + if ($originalRule instanceof ValidatorAwareRule) { + return true; + } + + if ($this->extensions === [] + || $check->ruleName === '' + || method_exists($this, 'validate' . $check->ruleName) + ) { + continue; + } + + if (isset($this->extensions[StrCache::snake($check->ruleName)])) { return true; } } @@ -555,138 +585,140 @@ protected function compiledPlansContainValidatorAwareRules(): bool } /** - * Pre-evaluate exclude_unless / exclude_if conditions before the main loop. + * Pre-evaluate safe first-position exclusion checks. * - * Only called for the base Validator with no extensions or validator-aware - * rules. Handles both string form ('exclude_unless:field,value') and - * array-tuple form. Safety-skips rules requiring parseDependentRuleParameters - * type conversions so they flow through the normal delegated path. + * @return array{0: array, 1: array} + * @phpstan-impure */ - protected function preEvaluateExclusions(): void + protected function preEvaluateExclusions(): array { - /** @var array $cache */ - $cache = []; + $preExcludedAttributes = []; + $unresolvedAttributes = []; + $possibleExclusionPrefixes = []; + $dataMayDiffer = false; + $canPreEvaluate = null; + /** @var array $exclusionOutcomes */ + $exclusionOutcomes = []; - foreach ($this->rules as $attribute => $attributeRules) { + foreach ($this->compiledPlans as $attribute => $plan) { $attribute = (string) $attribute; - if (! is_array($attributeRules)) { + if ($possibleExclusionPrefixes !== [] + && $this->hasAttributeAncestorInSet($attribute, $possibleExclusionPrefixes) + ) { + // Laravel removes an excluded descendant at its rule-map position. + // Each possible prefix is also pre-excluded or unresolved, so presence + // planning already declines this plan and every deeper descendant. + $dataMayDiffer = true; continue; } - foreach ($attributeRules as $rule) { - $parsed = $this->parseExcludeRule($rule); - if ($parsed === null) { - continue; - } + $firstExclusionIndex = null; + $hasLaterExclusion = false; - [$action, $conditionField, $allowedValues] = $parsed; + foreach ($plan->checks as $index => $check) { + if ($check instanceof DelegatedCheck && in_array($check->ruleName, $this->excludeRules, true)) { + $firstExclusionIndex ??= $index; - if (str_contains($conditionField, '*')) { - $conditionField = $this->resolveWildcardConditionField($attribute, $conditionField); - if (str_contains($conditionField, '*')) { - continue; + if ($index !== 0) { + $hasLaterExclusion = true; } } + } - if (array_intersect(['true', 'false', 'null'], $allowedValues) !== []) { - continue; - } + if ($firstExclusionIndex === null) { + continue; + } - $conditionRules = $this->rules[$conditionField] ?? []; - if (is_array($conditionRules) && in_array('boolean', $conditionRules, true)) { - continue; - } + if ($dataMayDiffer || $firstExclusionIndex !== 0) { + $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + continue; + } - if (! array_key_exists($conditionField, $cache)) { - $raw = data_get($this->data, $conditionField); - if (is_bool($raw) || $raw === null) { - $cache[$conditionField] = false; - } elseif (is_string($raw) || is_int($raw) || is_float($raw)) { - $cache[$conditionField] = $raw; - } else { - $cache[$conditionField] = false; - } - } + /** @var DelegatedCheck $firstCheck */ + $firstCheck = $plan->checks[0]; - $actual = $cache[$conditionField]; - if ($actual === false) { - continue; - } + if (! $firstCheck->parametersAreScalar) { + $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + continue; + } - $shouldExclude = ($action === 'exclude_unless' && ! in_array($actual, $allowedValues, false)) - || ($action === 'exclude_if' && in_array($actual, $allowedValues, false)); + $canPreEvaluate ??= ! $this->compiledPlansUseDataMutatingRules(); - if ($shouldExclude) { - $this->preExcludedAttributes[$attribute] = true; - break; - } + if (! $canPreEvaluate) { + $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + continue; } - } - } - /** - * Parse a single rule into [action, field, allowedValues] if it's an - * exclude_unless / exclude_if rule. - * - * @return null|array{0: string, 1: string, 2: list} - */ - private function parseExcludeRule(mixed $rule): ?array - { - if (is_string($rule)) { - foreach (['exclude_unless:', 'exclude_if:'] as $prefix) { - if (str_starts_with($rule, $prefix)) { - $action = rtrim($prefix, ':'); - $args = explode(',', substr($rule, strlen($prefix))); - if (count($args) < 2) { - return null; + try { + $parameters = $firstCheck->parameters; + $explicitKeys = []; + $dependsOnOtherFields = $this->dependsOnOtherFields($firstCheck->ruleName); + + if ($dependsOnOtherFields) { + $explicitKeys = $this->getExplicitKeys($attribute); + } + + // Built-in exclusions ignore the target attribute and value. Raw parameters + // retain the dependent wildcard pattern; captures complete its identity. + $outcomeKey = serialize([$firstCheck->ruleName, $parameters, $explicitKeys]); + + if (isset($exclusionOutcomes[$outcomeKey])) { + $passes = $exclusionOutcomes[$outcomeKey]; + } else { + if ($dependsOnOtherFields) { + $parameters = $this->replaceDotInParameters($parameters); + + if ($explicitKeys !== []) { + $parameters = $this->replaceAsterisksInParameters($parameters, $explicitKeys); + } } - return [$action, $args[0], array_slice($args, 1)]; + + $passes = match ($firstCheck->ruleName) { + 'Exclude' => $this->validateExclude(), + 'ExcludeIf' => $this->validateExcludeIf($attribute, $this->getValue($attribute), $parameters), + 'ExcludeUnless' => $this->validateExcludeUnless($attribute, $this->getValue($attribute), $parameters), + 'ExcludeWith' => $this->validateExcludeWith($attribute, $this->getValue($attribute), $parameters), + 'ExcludeWithout' => $this->validateExcludeWithout($attribute, $this->getValue($attribute), $parameters), + default => throw new LogicException("Unsupported exclusion rule [{$firstCheck->ruleName}]."), + }; + $exclusionOutcomes[$outcomeKey] = $passes; } + } catch (InvalidArgumentException|ValueError) { + $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + continue; } - return null; - } - if (is_array($rule) && count($rule) >= 3 && is_string($rule[0]) && is_string($rule[1])) { - $action = $rule[0]; - if ($action !== 'exclude_unless' && $action !== 'exclude_if') { - return null; + if (! $passes) { + $preExcludedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + continue; } - return [$action, $rule[1], array_map(strval(...), array_slice($rule, 2))]; - } - return null; - } - - /** - * Resolve * segments in a condition field reference by aligning with - * concrete indices in the attribute path. - * - * Example: attribute "interactions.5.style.top", condition "interactions.*.type" - * → "interactions.5.type" - */ - private function resolveWildcardConditionField(string $attribute, string $conditionField): string - { - preg_match_all('/\.(\d+)(?:\.|$)/', $attribute, $matches); - $indices = $matches[1]; - $i = 0; + if ($hasLaterExclusion) { + $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + } + } - return (string) preg_replace_callback('/\*/', static function () use ($indices, &$i) { - return $indices[$i++] ?? '*'; - }, $conditionField); + return [$preExcludedAttributes, $unresolvedAttributes]; } /** - * Build a PrecomputedPresenceVerifier for wildcard-expanded exists/unique - * rules by batching their lookups into single whereIn queries. + * Batch safe wildcard database-presence candidates by query shape. * - * Scans rules using the validator's own parsing methods (parseTable, - * getQueryColumn, getExtraConditions, getUniqueIds) so all rule forms - * (string, array, object) and table specifications (plain, model class, - * connection-prefixed) are handled correctly with zero duplication. - */ - protected function maybeBatchDatabaseChecks(DatabasePresenceVerifier $presenceVerifier): void - { + * @param array $preExcludedAttributes + * @param array $unresolvedExclusionAttributes + */ + protected function maybeBatchDatabaseChecks( + DatabasePresenceVerifier $presenceVerifier, + array $preExcludedAttributes, + array $unresolvedExclusionAttributes, + ): void { if ($this->implicitAttributes === []) { return; } @@ -698,32 +730,90 @@ protected function maybeBatchDatabaseChecks(DatabasePresenceVerifier $presenceVe $wildcardAttributeSet = array_flip($wildcardAttributes); - // Collect batchable groups from wildcard-expanded exists/unique rules. - // Groups are keyed by full query shape (not just table:column) so that - // rules with different wheres, ignore values, or types on the same - // table:column produce separate groups instead of merging silently. $groups = []; - foreach ($this->rules as $attribute => $attributeRules) { - if (! isset($wildcardAttributeSet[$attribute]) || ! is_array($attributeRules)) { + foreach ($this->compiledPlans as $attribute => $plan) { + $attribute = (string) $attribute; + + if (! isset($wildcardAttributeSet[$attribute])) { continue; } - foreach ($attributeRules as $rule) { - $meta = $this->extractPresenceRuleMeta($rule, (string) $attribute); + $firstPresenceIndex = null; - if ($meta === null) { + foreach ($plan->checks as $index => $check) { + if ($check instanceof DelegatedCheck + && ($check->ruleName === 'Exists' || $check->ruleName === 'Unique') + ) { + $firstPresenceIndex = $index; + break; + } + } + + if ($firstPresenceIndex === null) { + continue; + } + + $exists = Arr::has($this->data, $attribute); + + if (isset($preExcludedAttributes[$attribute]) + || ($preExcludedAttributes !== [] + && $this->hasAttributeAncestorInSet($attribute, $preExcludedAttributes)) + || ($plan->sometimes && ! $exists) + || ($unresolvedExclusionAttributes !== [] + && $this->hasAttributeAncestorInSet($attribute, $unresolvedExclusionAttributes)) + ) { + continue; + } + + $value = $this->getValue($attribute); + + if ($this->shouldSkipNonImplicitCheck($plan, $value, $exists) + || $this->shouldFailInvalidUpload($attribute, $value) + ) { + continue; + } + + for ($index = $firstPresenceIndex, $checkCount = count($plan->checks); $index < $checkCount; ++$index) { + $check = $plan->checks[$index]; + + if (! $check instanceof DelegatedCheck + || ($check->ruleName !== 'Exists' && $check->ruleName !== 'Unique') + ) { continue; } - $groupKey = $this->buildPresenceGroupKey($meta); - $groups[$groupKey] ??= ['meta' => $meta, 'values' => []]; + if (! $this->canBatchPresenceCandidate( + $plan, + $index, + $attribute, + $value, + ! isset($unresolvedExclusionAttributes[$attribute]), + )) { + continue; + } + + $meta = $this->extractPresenceRuleMeta($check, $attribute); + + if ($meta === null) { + continue; + } - $value = $this->getValue((string) $attribute); + $lookupKey = PrecomputedPresenceVerifier::lookupKey( + $meta['connection'], + $meta['table'], + $meta['column'], + $meta['ignore'], + $meta['idColumn'], + $meta['wheres'], + ); - if ($value !== null && $value !== '') { - $groups[$groupKey]['values'][] = $value; + if ($lookupKey === null) { + continue; } + + $groups[$lookupKey] ??= ['meta' => $meta, 'values' => []]; + $groups[$lookupKey]['values'][] = $value; } } @@ -731,19 +821,7 @@ protected function maybeBatchDatabaseChecks(DatabasePresenceVerifier $presenceVe return; } - // Build the set of table:column pairs represented in batchable groups, - // for collision checking against non-batchable rules. - $batchedTableColumns = []; - foreach ($groups as $group) { - $batchedTableColumns[$group['meta']['table'] . ':' . $group['meta']['column']] = true; - } - - // Scan ALL rules for non-batchable exists/unique on the same table:column. - // These would hit the global verifier at runtime, so precomputing for - // that table:column would give them wrong results. - $unsafeTableColumns = $this->collectUnsafeTableColumns($wildcardAttributeSet, $batchedTableColumns); - - $verifier = BatchDatabaseChecker::buildVerifier($groups, $presenceVerifier, $unsafeTableColumns); + $verifier = BatchDatabaseChecker::buildVerifier($groups, $presenceVerifier); if ($verifier === null) { return; @@ -754,43 +832,45 @@ protected function maybeBatchDatabaseChecks(DatabasePresenceVerifier $presenceVe } /** - * Extract metadata from a presence rule for batching. - * - * For object-form rules implementing DatabasePresenceRule, reads metadata - * directly via presenceMetadata() — no reflection, no stringification. - * For string-form and array-form rules, uses the validator's own - * parseTable/getQueryColumn for full parity with the normal validation path. + * Extract batch metadata from a compiled database-presence check. * - * Returns null if the rule is not exists/unique, not batchable (has - * closure callbacks, field-reference ignore), or can't be parsed. - * - * @return null|array{connection: null|string, table: string, column: string, wheres: array, ignore: mixed, idColumn: string, type: string} - */ - private function extractPresenceRuleMeta(mixed $rule, string $attribute): ?array - { - // Object-form: use the DatabasePresenceRule interface for exact metadata - if ($rule instanceof Contracts\DatabasePresenceRule) { - return $this->extractObjectPresenceRuleMeta($rule); + * @return null|array{ + * connection: ?string, + * table: string, + * column: string, + * wheres: array, + * ignore: null|int|string, + * idColumn: ?string + * } + */ + private function extractPresenceRuleMeta( + DelegatedCheck $check, + string $attribute, + ): ?array { + if (! $check->parametersAreScalar) { + return null; } - // String-form or array-form: parse and extract using validator methods - [$ruleName, $parameters] = ValidationRuleParser::parse($rule); - - if (! is_string($ruleName)) { + if (($check->originalRule instanceof Rules\Exists || $check->originalRule instanceof Rules\Unique) + && $check->originalRule->queryCallbacks() !== [] + ) { return null; } - $type = match ($ruleName) { + $type = match ($check->ruleName) { 'Exists' => 'exists', 'Unique' => 'unique', default => null, }; + $parameters = $check->parameters; + if ($type === null || ! isset($parameters[0])) { return null; } - [$connection, $table, $modelIdColumn] = $this->parseTable($parameters[0]); + $tableParameter = (string) $parameters[0]; + [$connection, $table, $modelIdColumn] = $this->parseTable($tableParameter); $column = $this->getQueryColumn($parameters, $attribute); if ($column === '' || $column === false) { @@ -798,7 +878,7 @@ private function extractPresenceRuleMeta(mixed $rule, string $attribute): ?array } $ignore = null; - $idColumn = $modelIdColumn ?? 'id'; + $idColumn = null; $wheres = []; if ($type === 'exists') { @@ -832,165 +912,45 @@ private function extractPresenceRuleMeta(mixed $rule, string $attribute): ?array 'wheres' => $wheres, 'ignore' => $ignore, 'idColumn' => $idColumn, - 'type' => $type, ]; } /** - * Extract batch metadata from a DatabasePresenceRule object. - * - * Uses presenceMetadata() for exact property access (no reflection, - * no __toString() truthy trap). Resolves the table via parseTable() - * for model class and connection handling. - * - * @return null|array{connection: null|string, table: string, column: string, wheres: array, ignore: mixed, idColumn: string, type: string} + * Determine if all checks before a presence rule are safe and pass. */ - private function extractObjectPresenceRuleMeta(Contracts\DatabasePresenceRule $rule): ?array - { - $meta = $rule->presenceMetadata(); - - // Not batchable if has closure query callbacks - if ($meta['using'] !== []) { - return null; - } + private function canBatchPresenceCandidate( + AttributePlan $plan, + int $presenceIndex, + string $attribute, + mixed $value, + bool $exclusionsResolved, + ): bool { + for ($checkIndex = 0; $checkIndex < $presenceIndex; ++$checkIndex) { + $check = $plan->checks[$checkIndex]; - // Column 'NULL' means infer at validation time — can't batch without knowing - if ($meta['column'] === 'NULL') { - return null; - } - - [$connection, $table] = $this->parseTable($meta['table']); - - $type = $rule instanceof Rules\Unique ? 'unique' : 'exists'; - - $ignore = $meta['ignore'] ?? null; - $idColumn = $meta['idColumn'] ?? 'id'; - $wheres = $meta['wheres']; + if ($check instanceof InlineCheck) { + if (! $this->canPreflightInline($check, $value) + || ! $this->executeInline($check, $value, $attribute) + ) { + return false; + } - // Normalize wheres from object format to the key => value format - // used by getExtraConditions / DatabasePresenceVerifier::addConditions - $normalizedWheres = []; - foreach ($wheres as $where) { - if (is_array($where) && isset($where['column'], $where['value'])) { - $normalizedWheres[$where['column']] = $where['value']; + continue; } - } - - return [ - 'connection' => $connection, - 'table' => $table, - 'column' => $meta['column'], - 'wheres' => $normalizedWheres, - 'ignore' => $ignore, - 'idColumn' => $idColumn, - 'type' => $type, - ]; - } - - /** - * Collect table:column pairs that are unsafe for precomputed lookups. - * - * Scans non-wildcard rules (all hit the global verifier) and unbatchable - * wildcard rules (object-form with closures, field-reference ignore, etc.) - * for exists/unique references on the same table:column pairs as the - * batchable groups. - * - * @param array $wildcardAttributeSet - * @param array $batchedTableColumns table:column pairs that have batchable groups - * @return array - */ - private function collectUnsafeTableColumns(array $wildcardAttributeSet, array $batchedTableColumns): array - { - $unsafe = []; - foreach ($this->rules as $attribute => $attributeRules) { - if (! is_array($attributeRules)) { + if ($exclusionsResolved && in_array($check->ruleName, $this->excludeRules, true)) { continue; } - $isWildcard = isset($wildcardAttributeSet[$attribute]); - - foreach ($attributeRules as $rule) { - [$ruleName] = ValidationRuleParser::parse($rule); - - if (! is_string($ruleName) || ! in_array($ruleName, ['Exists', 'Unique'], true)) { - continue; - } - - // For non-wildcard rules, ALL exists/unique hit the global verifier - if (! $isWildcard) { - $tc = $this->extractTableColumnForUnsafeCheck($rule, (string) $attribute); - if ($tc !== null && isset($batchedTableColumns[$tc])) { - $unsafe[$tc] = true; - } - continue; - } - - // For wildcard rules, only NON-batchable ones are unsafe - $meta = $this->extractPresenceRuleMeta($rule, (string) $attribute); - if ($meta === null) { - // Non-batchable — extract just table:column - $tc = $this->extractTableColumnForUnsafeCheck($rule, (string) $attribute); - if ($tc !== null && isset($batchedTableColumns[$tc])) { - $unsafe[$tc] = true; - } - } + if ($check->ruleName !== 'Required' + || is_object($value) + || ! $this->validateRequired($attribute, $value) + ) { + return false; } } - return $unsafe; - } - - /** - * Extract just the table:column pair from a presence rule for collision checking. - * - * Simpler than extractPresenceRuleMeta — only needs table and column, - * doesn't need wheres/ignore/idColumn. Works for all rule forms. - */ - private function extractTableColumnForUnsafeCheck(mixed $rule, string $attribute): ?string - { - if ($rule instanceof Contracts\DatabasePresenceRule) { - $meta = $rule->presenceMetadata(); - [, $table] = $this->parseTable($meta['table']); - $column = $meta['column'] !== 'NULL' ? $meta['column'] : $this->guessColumnForQuery($attribute); - - return $table . ':' . $column; - } - - [, $parameters] = ValidationRuleParser::parse($rule); - - if (! isset($parameters[0])) { - return null; - } - - [, $table] = $this->parseTable($parameters[0]); - $column = $this->getQueryColumn($parameters, $attribute); - - if ($column === '' || $column === false) { - return null; - } - - return $table . ':' . $column; - } - - /** - * Build a deterministic group key from presence rule metadata. - * - * Encodes the full query shape (connection, table, column, type, wheres, - * ignore, idColumn) so that rules with different query shapes on the same - * table:column produce separate batch groups instead of merging silently. - * - * @param array{connection: null|string, table: string, column: string, wheres: array, ignore: mixed, idColumn: string, type: string} $meta - */ - private function buildPresenceGroupKey(array $meta): string - { - return $meta['type'] - . ':' . ($meta['connection'] ?? '') - . ':' . $meta['table'] - . ':' . $meta['column'] - . ':' . $meta['idColumn'] - . ':' . (is_scalar($meta['ignore']) ? (string) $meta['ignore'] : '') - . ':' . json_encode($meta['wheres'], JSON_THROW_ON_ERROR); + return true; } /** @@ -1036,6 +996,12 @@ public function whenFails(callable $callback, ?callable $default = null): mixed */ protected function shouldBeExcluded(string $attribute): bool { + if (static::class === self::class) { + return isset($this->activeExclusions[$attribute]) + || ($this->activeExclusions !== [] + && $this->hasAttributeAncestorInSet($attribute, $this->activeExclusions)); + } + foreach ($this->excludeAttributes as $excludeAttribute) { if ($attribute === $excludeAttribute || Str::startsWith($attribute, $excludeAttribute . '.') @@ -1048,21 +1014,16 @@ protected function shouldBeExcluded(string $attribute): bool } /** - * Determine if the attribute or any of its ancestors was pre-excluded. + * Determine if any strict ancestor of an attribute belongs to a set. * - * Walks up the dot-separated segments checking each prefix against - * $preExcludedAttributes. O(depth) where depth is typically 2-4. + * @param array $attributes */ - private function isPreExcludedOrDescendant(string $attribute): bool + private function hasAttributeAncestorInSet(string $attribute, array $attributes): bool { - if (isset($this->preExcludedAttributes[$attribute])) { - return true; - } - $position = 0; while (($position = strpos($attribute, '.', $position)) !== false) { - if (isset($this->preExcludedAttributes[substr($attribute, 0, $position)])) { + if (isset($attributes[substr($attribute, 0, $position)])) { return true; } ++$position; @@ -1382,7 +1343,9 @@ protected function isNotNullIfMarkedAsNullable(object|string $rule, string $attr */ protected function hasNotFailedPreviousRuleIfPresenceRule(object|string $rule, string $attribute): bool { - return in_array($rule, ['Unique', 'Exists']) ? ! $this->messages->has($attribute) : true; + return in_array($rule, ['Unique', 'Exists'], true) + ? ! $this->messages->has($this->replacePlaceholderInString($attribute)) + : true; } /** @@ -1478,8 +1441,8 @@ public function addFailure(string $attribute, string $rule, array $parameters = $attribute = $this->replacePlaceholderInString($attribute); - if (in_array($rule, $this->excludeRules)) { - $this->excludeAttribute($attribute); + if (in_array($rule, $this->excludeRules, true)) { + $this->excludeAttribute($attributeWithPlaceholders); return; } @@ -1502,6 +1465,12 @@ public function addFailure(string $attribute, string $rule, array $parameters = */ protected function excludeAttribute(string $attribute): void { + if (static::class === self::class) { + $this->activeExclusions[$attribute] = true; + + return; + } + $this->excludeAttributes[] = $attribute; $this->excludeAttributes = array_unique($this->excludeAttributes); diff --git a/tests/Integration/Validation/Database/MariaDb/ValidationBatchDatabaseCheckerTest.php b/tests/Integration/Validation/Database/MariaDb/ValidationBatchDatabaseCheckerTest.php new file mode 100644 index 0000000000..06ee593e97 --- /dev/null +++ b/tests/Integration/Validation/Database/MariaDb/ValidationBatchDatabaseCheckerTest.php @@ -0,0 +1,13 @@ +id(); + $table->integer('external_id')->unique(); + $table->string('email')->unique(); + $table->string('status')->default('active'); + $table->string('lookup_value')->nullable(); + $table->decimal('score', 8, 2)->nullable(); + $table->date('joined_on')->nullable(); + $table->timestamp('scheduled_at')->nullable(); + }); + + $this->app->make('db')->table('batch_test_users')->insert([ + ['external_id' => 0, 'email' => 'user1@example.com', 'status' => 'active', 'lookup_value' => 'Case', 'score' => 1.25, 'joined_on' => '2025-01-01', 'scheduled_at' => '2025-01-01 00:00:00'], + ['external_id' => 1, 'email' => 'user2@example.com', 'status' => 'active', 'lookup_value' => 'café', 'score' => 2.50, 'joined_on' => '2025-01-02', 'scheduled_at' => null], + ['external_id' => 2, 'email' => 'user3@example.com', 'status' => 'inactive', 'lookup_value' => 'trimmed', 'score' => 3.75, 'joined_on' => '2025-01-03', 'scheduled_at' => null], + ['external_id' => 100, 'email' => 'numeric@example.com', 'status' => 'active', 'lookup_value' => '1', 'score' => null, 'joined_on' => null, 'scheduled_at' => null], + ]); + } + + public function testBuildVerifierReturnsNullWhenNoLookups(): void + { + $presenceVerifier = $this->app->make('validation.presence'); + $this->assertInstanceOf(DatabasePresenceVerifier::class, $presenceVerifier); + + $this->assertNull(BatchDatabaseChecker::buildVerifier([], $presenceVerifier)); + } + + public function testDistinctCountSemanticsForArrayValuedExists(): void + { + $presenceVerifier = $this->app->make('validation.presence'); + $this->assertInstanceOf(DatabasePresenceVerifier::class, $presenceVerifier); + $verifier = new PrecomputedPresenceVerifier($presenceVerifier); + $lookupKey = PrecomputedPresenceVerifier::lookupKey(null, 'batch_test_users', 'email'); + $bindingKey = PrecomputedPresenceVerifier::bindingKey('user1@example.com'); + $this->assertNotNull($lookupKey); + $this->assertNotNull($bindingKey); + $verifier->addLookup( + $lookupKey, + exactHits: [$bindingKey => true], + knownPresent: [], + provenAbsent: [], + stageOneSingleChunk: true, + ); + + $count = $verifier->getMultiCount( + 'batch_test_users', + 'email', + ['user1@example.com', 'user1@example.com'], + ); + + $this->assertSame(1, $count); + } + + // ─── End-to-end validator tests ────────────────────────────────────── + + public function testBatchingActivatesEndToEndForStringFormExists(): void + { + $data = ['items' => []]; + for ($i = 0; $i < 10; ++$i) { + $data['items'][] = ['email' => 'user' . (($i % 3) + 1) . '@example.com']; + } + + $validator = $this->makeValidator($data, [ + 'items.*.email' => 'required|exists:batch_test_users,email', + ]); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + + $existsQueries = array_filter($queryLog, function ($entry) { + return str_contains($entry['query'], 'batch_test_users'); + }); + + $this->assertCount(1, $existsQueries); + } + + public function testStringableCandidateUsesOrdinaryPresenceQueryWithoutDisablingSafeBatch(): void + { + $stringable = new ValidationPresenceStringable('user1@example.com'); + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user2@example.com'], + ['email' => $stringable], + ]], + ['items.*.email' => 'required|exists:batch_test_users,email'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertSame(1, $stringable->casts); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testStringablePresenceParameterUsesOrdinaryConversionOnce(): void + { + $condition = new ValidationPresenceStringable('active'); + $validator = $this->makeValidator( + ['items' => [['email' => 'user1@example.com']]], + ['items.*.email' => ['required', ['exists', 'batch_test_users', 'email', 'status', $condition]]], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertSame(1, $condition->casts); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testBatchingProducesCorrectPassFailResults(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'nonexistent@example.com'], + ['email' => 'user3@example.com'], + ]], + ['items.*.email' => 'required|exists:batch_test_users,email'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('items.1.email')); + $this->assertFalse($validator->errors()->has('items.0.email')); + $this->assertFalse($validator->errors()->has('items.2.email')); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testAllNewUniqueValuesUseOneGroupedQueryWithoutFallbacks(): void + { + $items = []; + + for ($index = 0; $index < 10; ++$index) { + $items[] = ['email' => "new-{$index}@example.com"]; + } + + $validator = $this->makeValidator( + ['items' => $items], + ['items.*.email' => 'required|unique:batch_test_users,email'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testBatchedPresenceMatchesOrdinaryVerifierDatabaseEquality(): void + { + $probes = [ + ['lookup_value', 'case'], + ['lookup_value', 'cafe'], + ['lookup_value', 'trimmed '], + ['score', 1.25], + ['score', '2.50'], + ['external_id', '1'], + ['external_id', 1.0], + ]; + + foreach ($probes as [$column, $value]) { + foreach (['exists', 'unique'] as $rule) { + $ordinary = $this->makeValidator( + ['value' => $value], + ['value' => "{$rule}:batch_test_users,{$column}"], + ); + $batched = $this->makeValidator( + ['items' => [['value' => $value]]], + ['items.*.value' => "{$rule}:batch_test_users,{$column}"], + ); + + $this->assertSame( + $ordinary->passes(), + $batched->passes(), + "Batched {$rule} diverged for {$column} and " . var_export($value, true), + ); + } + } + } + + public function testDateTimeCandidatesUseTheOrdinaryVerifierBindingConversion(): void + { + $value = new ValidationPresenceDomainDate('2025-01-01 00:00:00'); + + foreach (['exists', 'unique'] as $rule) { + $ordinary = $this->makeValidator( + ['value' => $value], + ['value' => "{$rule}:batch_test_users,scheduled_at"], + ); + $batched = $this->makeValidator( + ['items' => [['value' => $value]]], + ['items.*.value' => "{$rule}:batch_test_users,scheduled_at"], + ); + + $this->assertSame($ordinary->passes(), $batched->passes()); + } + } + + #[RequiresDatabase(['mysql', 'mariadb', 'sqlite'])] + public function testIntegerCandidateAgainstTextUsesOnePrecomputedQueryWhereSupported(): void + { + foreach (['exists', 'unique'] as $rule) { + $ordinary = $this->makeValidator( + ['value' => 1], + ['value' => "{$rule}:batch_test_users,lookup_value"], + ); + $batched = $this->makeValidator( + ['items' => [['value' => 1]]], + ['items.*.value' => "{$rule}:batch_test_users,lookup_value"], + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + try { + $result = $batched->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertSame($ordinary->passes(), $result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + } + + #[RequiresDatabase(['mysql', 'mariadb', 'sqlite'])] + public function testMixedStringAndIntegerTextCandidatesRetainBothBindings(): void + { + $validator = $this->makeValidator( + ['items' => [['value' => '1'], ['value' => 1]]], + ['items.*.value' => 'exists:batch_test_users,lookup_value'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + + $presenceQueries = array_values(array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + + $this->assertCount(1, $presenceQueries); + $this->assertSame(['1', 1], $presenceQueries[0]['bindings']); + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testMixedStringAndIntegerCandidatesPreserveOrdinaryMySqlTextComparison(): void + { + $this->app->make('db')->table('batch_test_users') + ->where('external_id', 100) + ->update(['lookup_value' => '01']); + + $ordinaryString = $this->makeValidator( + ['value' => '1'], + ['value' => 'exists:batch_test_users,lookup_value'], + ); + $ordinaryInteger = $this->makeValidator( + ['value' => 1], + ['value' => 'exists:batch_test_users,lookup_value'], + ); + + $this->assertFalse($ordinaryString->passes()); + $this->assertTrue($ordinaryInteger->passes()); + + foreach ([['1', 1], [1, '1']] as $values) { + $validator = $this->makeValidator( + ['items' => array_map( + static fn (int|string $value): array => ['value' => $value], + $values, + )], + ['items.*.value' => 'exists:batch_test_users,lookup_value'], + ); + + $this->assertFalse($validator->passes()); + + foreach ($values as $index => $value) { + $this->assertSame( + is_string($value), + $validator->errors()->has("items.{$index}.value"), + ); + } + } + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testCaseInsensitiveUniqueUsesDatabaseEquality(): void + { + $validator = $this->makeValidator( + ['items' => [['email' => 'USER1@EXAMPLE.COM']]], + ['items.*.email' => 'required|unique:batch_test_users,email'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('items.0.email')); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + #[RequiresDatabase(['mysql', 'mariadb'])] + public function testArrayPresenceUsesDatabaseDistinctEquivalenceClasses(): void + { + $this->app->make('db')->table('batch_test_users')->insert([ + 'external_id' => 3, + 'email' => 'user4@example.com', + 'status' => 'active', + 'lookup_value' => 'case', + ]); + + $presenceVerifier = $this->app->make('validation.presence'); + $this->assertInstanceOf(DatabasePresenceVerifier::class, $presenceVerifier); + $this->assertCount(1, $presenceVerifier->getExistingValues( + 'batch_test_users', + 'lookup_value', + ['Case', 'case'], + null, + )); + + $ordinary = $this->makeValidator( + ['values' => ['Case', 'case']], + ['values' => 'array|exists:batch_test_users,lookup_value'], + ); + $batched = $this->makeValidator( + ['items' => [['values' => ['Case', 'case']]]], + ['items.*.values' => 'array|exists:batch_test_users,lookup_value'], + ); + + $this->assertFalse($ordinary->passes()); + $this->assertFalse($batched->passes()); + } + + #[RequiresDatabase('pgsql')] + public function testInvalidTypedValuesFailBeforeAnyPresenceQuery(): void + { + $validator = $this->makeValidator( + ['items' => [[ + 'external_id' => 'not-an-integer', + 'date' => 'not-a-date', + 'formatted_date' => '01/02/2025', + ]]], + [ + 'items.*.external_id' => 'integer|exists:batch_test_users,external_id', + 'items.*.date' => 'date|exists:batch_test_users,joined_on', + 'items.*.formatted_date' => 'date_format:Y-m-d|exists:batch_test_users,joined_on', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('items.0.external_id')); + $this->assertTrue($validator->errors()->has('items.0.date')); + $this->assertTrue($validator->errors()->has('items.0.formatted_date')); + $this->assertCount(0, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + #[RequiresDatabase('pgsql')] + public function testPostgresPreservesIntegerToTextBindingErrorsOnOrdinaryAndBatchedPaths(): void + { + $ordinary = $this->makeValidator( + ['value' => 1], + ['value' => 'exists:batch_test_users,lookup_value'], + ); + + $this->assertThrows($ordinary->passes(...), QueryException::class); + + foreach ([['1', 1], [1, '1']] as $values) { + $batched = $this->makeValidator( + ['items' => array_map( + static fn (int|string $value): array => ['value' => $value], + $values, + )], + ['items.*.value' => 'exists:batch_test_users,lookup_value'], + ); + + $this->assertThrows($batched->passes(...), QueryException::class); + } + } + + public function testOriginalPresenceVerifierIsRestoredAfterExceptionDuringBatchedValidation(): void + { + $validator = $this->makeValidator( + [ + 'items' => [['email' => 'user1@example.com']], + 'boom' => 'trigger', + ], + [ + 'items.*.email' => 'required|exists:batch_test_users,email', + 'boom' => [function (string $attribute, mixed $value, Closure $fail): void { + throw new RuntimeException('boom'); + }], + ], + ); + + $originalVerifier = $validator->getPresenceVerifier(); + + try { + $validator->passes(); + $this->fail('Expected RuntimeException was not thrown.'); + } catch (RuntimeException $e) { + $this->assertSame('boom', $e->getMessage()); + } + + $this->assertSame($originalVerifier, $validator->getPresenceVerifier()); + } + + public function testDifferentWildcardQueryShapesOnSameTableColumnBatchIndependently(): void + { + $validator = $this->makeValidator( + ['items' => [ + [ + 'active_email' => 'user1@example.com', + 'any_email' => 'user3@example.com', + ], + ]], + [ + 'items.*.active_email' => 'required|exists:batch_test_users,email,status,active', + 'items.*.any_email' => 'required|exists:batch_test_users,email', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testNonWildcardRuleCanConsumeFactsFromAnIdenticalWildcardShape(): void + { + $validator = $this->makeValidator( + [ + 'email' => 'user1@example.com', + 'items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], + ], + [ + 'email' => 'required|exists:batch_test_users,email', + 'items.*.email' => 'required|exists:batch_test_users,email', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testArrayFormExistsRuleWithExtraConditionsKeepsFactsIsolatedByQueryShape(): void + { + $validator = $this->makeValidator( + [ + 'email' => 'user3@example.com', + 'items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], + ], + [ + 'email' => [['exists', 'batch_test_users', 'email', 'status', 'active']], + 'items.*.email' => 'required|exists:batch_test_users,email', + ], + ); + + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('email')); + $this->assertFalse($validator->errors()->has('items.0.email')); + $this->assertFalse($validator->errors()->has('items.1.email')); + } + + public function testFieldReferenceIgnoreIsNotBatched(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com', 'id' => 1], + ['email' => 'user2@example.com', 'id' => 2], + ]], + [ + 'items.*.email' => 'required|unique:batch_test_users,email,[items.*.id]', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testModelClassRuleResolvesCorrectly(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ]], + ['items.*.email' => 'required|exists:' . BatchTestUser::class . ',email'], + ); + + $this->assertTrue($validator->passes()); + } + + public function testModelTableParsingIsMemoizedWithinEachValidationExecution(): void + { + CountingBatchTestUser::$constructions = 0; + + try { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ['email' => 'user3@example.com'], + ]], + ['items.*.email' => 'required|exists:' . CountingBatchTestUser::class . ',email'], + ); + + $this->assertTrue($validator->passes()); + $this->assertSame(1, CountingBatchTestUser::$constructions); + $this->assertTrue($validator->passes()); + $this->assertSame(2, CountingBatchTestUser::$constructions); + } finally { + CountingBatchTestUser::$constructions = 0; + } + } + + public function testObjectFormExistsRulesBatchCorrectly(): void + { + $rule = new Exists('batch_test_users', 'email'); + + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ]], + ['items.*.email' => ['required', $rule]], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testObjectFormUniqueRuleWithIgnoreBatchesCorrectly(): void + { + $rule = (new Unique('batch_test_users', 'email')) + ->ignore(1, 'id'); + + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ['email' => 'new@example.com'], + ]], + ['items.*.email' => ['required', $rule]], + ); + + $this->assertFalse($validator->passes()); + $this->assertFalse($validator->errors()->has('items.0.email')); + $this->assertTrue($validator->errors()->has('items.1.email')); + $this->assertFalse($validator->errors()->has('items.2.email')); + } + + public function testObjectFormUniqueRulePreservesZeroIgnoreWhenBatched(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'new@example.com'], + ]], + ['items.*.email' => [ + 'required', + (new Unique('batch_test_users', 'email'))->ignore(0, 'external_id'), + ]], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testCallbackBearingPresenceRulesRemainDelegated(): void + { + $existsCallbackCalls = 0; + $uniqueCallbackCalls = 0; + $exists = (new Exists('batch_test_users', 'email'))->where( + function ($query) use (&$existsCallbackCalls): void { + ++$existsCallbackCalls; + $query->where('status', 'active'); + }, + ); + $unique = (new Unique('batch_test_users', 'email'))->where( + function ($query) use (&$uniqueCallbackCalls): void { + ++$uniqueCallbackCalls; + $query->where('status', 'active'); + }, + ); + $validator = $this->makeValidator( + ['items' => [ + ['existing' => 'user1@example.com', 'unique' => 'user1@example.com'], + ['existing' => 'user3@example.com', 'unique' => 'user3@example.com'], + ]], + [ + 'items.*.existing' => ['required', $exists], + 'items.*.unique' => ['required', $unique], + ], + ); + + $this->assertFalse($validator->passes()); + $this->assertFalse($validator->errors()->has('items.0.existing')); + $this->assertTrue($validator->errors()->has('items.1.existing')); + $this->assertTrue($validator->errors()->has('items.0.unique')); + $this->assertFalse($validator->errors()->has('items.1.unique')); + $this->assertSame(2, $existsCallbackCalls); + $this->assertSame(2, $uniqueCallbackCalls); + } + + public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): void + { + $email = 'slash\id@example.com'; + + $this->app->make('db')->table('batch_test_users')->insert([ + 'external_id' => 3, + 'email' => $email, + 'status' => 'active', + ]); + + $rule = (string) (new Unique('batch_test_users', 'email')) + ->ignore($email, 'email'); + + $validator = $this->makeValidator( + ['items' => [ + ['email' => $email], + ['email' => 'new@example.com'], + ]], + ['items.*.email' => ['required', $rule]], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + + $uniqueQueries = array_filter($queryLog, function ($entry) { + return str_contains($entry['query'], 'batch_test_users'); + }); + + $this->assertCount(1, $uniqueQueries); + } + + public function testArrayFormExistsRuleCanConsumeFactsFromIdenticalWildcardShape(): void + { + $validator = $this->makeValidator( + [ + 'email' => 'user1@example.com', + 'items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], + ], + [ + 'email' => [['exists', 'batch_test_users', 'email']], + 'items.*.email' => 'required|exists:batch_test_users,email', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testNonBatchableStringWildcardRuleDoesNotCorruptResults(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com', 'id' => 1], + ['email' => 'user2@example.com', 'id' => 2], + ]], + [ + 'items.*.email' => [ + 'required', + 'exists:batch_test_users,email', + 'unique:batch_test_users,email,[items.*.id]', + ], + ], + ); + + $this->assertTrue($validator->passes()); + } + + public function testObjectFormExistsWithInferredColumnKeepsFactsIsolatedByQueryShape(): void + { + $rule = (new Exists('batch_test_users')) + ->where('status', 'active'); + + $validator = $this->makeValidator( + [ + 'email' => 'user3@example.com', + 'items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], + ], + [ + 'email' => ['required', $rule], + 'items.*.email' => 'required|exists:batch_test_users,email', + ], + ); + + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('email')); + $this->assertFalse($validator->errors()->has('items.0.email')); + $this->assertFalse($validator->errors()->has('items.1.email')); + } + + public function testArrayValuedExistsRulesBatchOneDimensionalValues(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['emails' => ['user1@example.com', 'user2@example.com', 'user1@example.com']], + ['emails' => ['user3@example.com', 'missing@example.com']], + ]], + ['items.*.emails' => ['required', 'array', 'exists:batch_test_users,email']], + ); + + $this->assertFalse($validator->passes()); + $this->assertFalse($validator->errors()->has('items.0.emails')); + $this->assertTrue($validator->errors()->has('items.1.emails')); + } + + public function testSafePrefixesSubmitOnlyValuesThatCanReachPresenceValidation(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['external_id' => 1], + ['external_id' => 'invalid'], + ['external_id' => 2], + ]], + ['items.*.external_id' => 'required|integer|exists:batch_test_users,external_id'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertFalse($validator->errors()->has('items.0.external_id')); + $this->assertTrue($validator->errors()->has('items.1.external_id')); + $this->assertFalse($validator->errors()->has('items.2.external_id')); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testResolvedFirstExclusionKeepsPresenceChecksBatchable(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['type' => 'chapter', 'email' => 'user1@example.com'], + ['type' => 'chapter', 'email' => 'user2@example.com'], + ['type' => 'chapter', 'email' => 'user3@example.com'], + ]], + ['items.*.email' => 'exclude_if:items.*.type,none|required|exists:batch_test_users,email'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testRemovedDescendantMakesLaterExclusionUncertainWithoutDisablingUnrelatedBatch(): void + { + $validator = $this->makeValidator( + [ + 'parent' => ['child' => 'trigger'], + 'conditional' => [ + ['external_id' => 1], + ['external_id' => 2], + ], + 'items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], + ], + [ + 'parent' => 'exclude', + 'parent.child' => 'string', + 'conditional.*.external_id' => 'exclude_if:parent.child,trigger|required|exists:batch_test_users,external_id', + 'items.*.email' => 'required|exists:batch_test_users,email', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(3, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testFutureParentExclusionConservativelyLeavesEarlierDescendantPresenceChecksOrdered(): void + { + $validator = $this->makeValidator( + [ + 'flag' => 'exclude', + 'items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], + ], + [ + 'items.0.email' => 'required', + 'items' => 'exclude_if:flag,exclude', + 'items.*.email' => 'exists:batch_test_users,email', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertSame(['flag' => 'exclude'], $validator->getData()); + $presenceQueries = array_values(array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + $this->assertCount(1, $presenceQueries); + $this->assertContains('user1@example.com', $presenceQueries[0]['bindings']); + $this->assertNotContains('user2@example.com', $presenceQueries[0]['bindings']); + } + + public function testMutationGateKeepsFirstExclusionPresenceChecksDelegated(): void + { + $validator = $this->makeValidator( + [ + 'prepare' => true, + 'items' => [ + ['type' => 'chapter', 'email' => 'user1@example.com'], + ['type' => 'chapter', 'email' => 'user2@example.com'], + ['type' => 'chapter', 'email' => 'user3@example.com'], + ], + ], + [ + 'prepare' => 'prepare_items', + 'items.*.email' => 'exclude_if:items.*.type,none|required|exists:batch_test_users,email', + ], + ); + $validator->addExtension('prepare_items', static fn (): bool => true); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(3, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testUnsafeFailingPrefixDoesNotIssueAPresenceQuery(): void + { + $validator = $this->makeValidator( + ['items' => [['external_id' => 'invalid']]], + ['items.*.external_id' => 'multiple_of:5|exists:batch_test_users,external_id'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('items.0.external_id')); + $this->assertCount(0, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testUncertainPassingPrefixFallsBackWithoutDisablingSafeSiblingBatching(): void + { + $validator = $this->makeValidator( + ['items' => [[ + 'safe_id' => 1, + 'uncertain_id' => 2, + ]]], + [ + 'items.*.safe_id' => 'required|integer|exists:batch_test_users,external_id', + 'items.*.uncertain_id' => ['regex:/^\d+$/', 'exists:batch_test_users,external_id'], + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testBooleanCandidateFallsBackWithoutDisablingSafeSiblingBatching(): void + { + $validator = $this->makeValidator( + ['items' => [[ + 'safe_value' => 'user1@example.com', + 'boolean_value' => true, + ]]], + [ + 'items.*.safe_value' => 'required|exists:batch_test_users,email', + 'items.*.boolean_value' => 'required|exists:batch_test_users,email', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertFalse($validator->errors()->has('items.0.safe_value')); + $this->assertTrue($validator->errors()->has('items.0.boolean_value')); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testStopOnFirstFailureSkipsSpeculativePresenceBatching(): void + { + $validator = $this->makeValidator( + ['items' => [['value' => 1], ['value' => 2]]], + [ + 'name' => 'required', + 'items.*.value' => 'exists:batch_test_users,lookup_value', + ], + )->stopOnFirstFailure(); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('name')); + $this->assertFalse($validator->errors()->has('items.0.value')); + $this->assertCount(0, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testPresenceBatchingRemainsEnabledWithoutStopOnFirstFailure(): void + { + $validator = $this->makeValidator( + ['items' => [['value' => 'Case']]], + [ + 'name' => 'required', + 'items.*.value' => 'exists:batch_test_users,lookup_value', + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('name')); + $this->assertFalse($validator->errors()->has('items.0.value')); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testTypelessSizePrefixRemainsBatchable(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ]], + ['items.*.email' => 'required|max:255|exists:batch_test_users,email'], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testUnusedExtensionDoesNotSuppressExclusionOrUnrelatedBatching(): void + { + $validator = $this->makeValidator( + [ + 'type' => 'section', + 'appointments' => [ + ['email' => 'invalid-one'], + ['email' => 'invalid-two'], + ], + 'items' => [['email' => 'user1@example.com']], + ], + [ + 'appointments' => 'exclude_unless:type,chapter|required|array', + 'appointments.*.email' => 'required|exists:batch_test_users,email', + 'items.*.email' => 'required|exists:batch_test_users,email', + ], + ); + $validator->addExtension('unused', static fn (): bool => true); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testUsedMutatorSuppressesOnlyAffectedDescendantBatching(): void + { + $validator = $this->makeValidator( + [ + 'prepare' => true, + 'type' => 'section', + 'appointments' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], + 'items' => [['email' => 'user3@example.com']], + ], + [ + 'prepare' => 'prepare_type', + 'appointments' => 'exclude_unless:type,chapter|required|array', + 'appointments.*.email' => 'required|exists:batch_test_users,email', + 'items.*.email' => 'required|exists:batch_test_users,email', + ], + ); + $validator->addExtension( + 'prepare_type', + function (string $attribute, mixed $value, array $parameters, Validator $currentValidator): bool { + $currentValidator->setValue('type', 'chapter'); + + return true; + }, + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(3, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testValidatorAwareMutationFallsBackWithoutDisablingSafeSiblingBatching(): void + { + $validator = $this->makeValidator( + ['items' => [[ + 'safe_email' => 'new@example.com', + 'mutated_email' => 'user1@example.com', + ]]], + [ + 'items.*.safe_email' => 'required|unique:batch_test_users,email', + 'items.*.mutated_email' => [ + new class implements Rule, ValidatorAwareRule { + private Validator $validator; + + public function setValidator(Validator $validator): static + { + $this->validator = $validator; + + return $this; + } + + public function passes(string $attribute, mixed $value): bool + { + $this->validator->setValue($attribute, 'user2@example.com'); + + return true; + } + + public function message(): string + { + return 'The value could not be prepared.'; + } + }, + 'unique:batch_test_users,email', + ], + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertFalse($validator->errors()->has('items.0.safe_email')); + $this->assertTrue($validator->errors()->has('items.0.mutated_email')); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testValidatorAwareMutationCanConsumeAnotherSubmittedValueFact(): void + { + $validator = $this->makeValidator( + ['items' => [[ + 'submitted_email' => 'user2@example.com', + 'mutated_email' => 'user1@example.com', + ]]], + [ + 'items.*.submitted_email' => 'required|unique:batch_test_users,email', + 'items.*.mutated_email' => [ + new class implements Rule, ValidatorAwareRule { + private Validator $validator; + + public function setValidator(Validator $validator): static + { + $this->validator = $validator; + + return $this; + } + + public function passes(string $attribute, mixed $value): bool + { + $this->validator->setValue($attribute, 'user2@example.com'); + + return true; + } + + public function message(): string + { + return 'The value could not be prepared.'; + } + }, + 'unique:batch_test_users,email', + ], + ], + ); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('items.0.submitted_email')); + $this->assertTrue($validator->errors()->has('items.0.mutated_email')); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + private function makeValidator(array $data, array $rules): Validator + { + $translator = new Translator(new ArrayLoader, 'en'); + $validator = new Validator($translator, $data, $rules); + $validator->setPresenceVerifier($this->app->make('validation.presence')); + + return $validator; + } +} + +class BatchTestUser extends Model +{ + protected ?string $table = 'batch_test_users'; +} + +class CountingBatchTestUser extends Model +{ + public static int $constructions = 0; + + protected ?string $table = 'batch_test_users'; + + public function __construct(array $attributes = []) + { + ++self::$constructions; + + parent::__construct($attributes); + } +} + +class ValidationPresenceDomainDate extends DateTimeImmutable implements Stringable +{ + public function __toString(): string + { + return $this->format(DATE_ATOM); + } +} + +class ValidationPresenceStringable implements Stringable +{ + public int $casts = 0; + + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + ++$this->casts; + + return $this->value; + } +} diff --git a/tests/Integration/Validation/ValidationBatchDatabaseCheckerTest.php b/tests/Integration/Validation/ValidationBatchDatabaseCheckerTest.php deleted file mode 100644 index c8222c2ac8..0000000000 --- a/tests/Integration/Validation/ValidationBatchDatabaseCheckerTest.php +++ /dev/null @@ -1,428 +0,0 @@ -id(); - $table->string('email')->unique(); - $table->string('status')->default('active'); - }); - - $this->app->make('db')->table('batch_test_users')->insert([ - ['email' => 'user1@example.com', 'status' => 'active'], - ['email' => 'user2@example.com', 'status' => 'active'], - ['email' => 'user3@example.com', 'status' => 'inactive'], - ]); - } - - public function testBuildVerifierReturnsNullWhenNoLookups(): void - { - $presenceVerifier = $this->app->make('validation.presence'); - $this->assertInstanceOf(DatabasePresenceVerifier::class, $presenceVerifier); - - $this->assertNull(BatchDatabaseChecker::buildVerifier([], $presenceVerifier)); - } - - public function testDistinctCountSemanticsForArrayValuedExists(): void - { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('batch_test_users', 'email', ['user1@example.com']); - - $count = $verifier->getMultiCount( - 'batch_test_users', - 'email', - ['user1@example.com', 'user1@example.com'], - ); - - $this->assertSame(1, $count); - } - - // ─── End-to-end validator tests ────────────────────────────────────── - - public function testBatchingActivatesEndToEndForStringFormExists(): void - { - $data = ['items' => []]; - for ($i = 0; $i < 10; ++$i) { - $data['items'][] = ['email' => 'user' . (($i % 3) + 1) . '@example.com']; - } - - $validator = $this->makeValidator($data, [ - 'items.*.email' => 'required|exists:batch_test_users,email', - ]); - - DB::enableQueryLog(); - - try { - $result = $validator->passes(); - $queryLog = DB::getQueryLog(); - } finally { - DB::disableQueryLog(); - } - - $this->assertTrue($result); - - $existsQueries = array_filter($queryLog, function ($entry) { - return str_contains($entry['query'], 'batch_test_users'); - }); - - $this->assertLessThanOrEqual(2, count($existsQueries), 'Batching should collapse N exists queries into 1-2 batch queries'); - } - - public function testBatchingProducesCorrectPassFailResults(): void - { - $validator = $this->makeValidator( - ['items' => [ - ['email' => 'user1@example.com'], - ['email' => 'nonexistent@example.com'], - ['email' => 'user3@example.com'], - ]], - ['items.*.email' => 'required|exists:batch_test_users,email'], - ); - - $this->assertFalse($validator->passes()); - $this->assertTrue($validator->errors()->has('items.1.email')); - $this->assertFalse($validator->errors()->has('items.0.email')); - $this->assertFalse($validator->errors()->has('items.2.email')); - } - - public function testOriginalPresenceVerifierIsRestoredAfterExceptionDuringBatchedValidation(): void - { - $validator = $this->makeValidator( - [ - 'items' => [['email' => 'user1@example.com']], - 'boom' => 'trigger', - ], - [ - 'items.*.email' => 'required|exists:batch_test_users,email', - 'boom' => [function (string $attribute, mixed $value, Closure $fail): void { - throw new RuntimeException('boom'); - }], - ], - ); - - $originalVerifier = $validator->getPresenceVerifier(); - - try { - $validator->passes(); - $this->fail('Expected RuntimeException was not thrown.'); - } catch (RuntimeException $e) { - $this->assertSame('boom', $e->getMessage()); - } - - $this->assertSame($originalVerifier, $validator->getPresenceVerifier()); - } - - public function testDifferentWildcardQueryShapesOnSameTableColumnFallBackToRealVerifier(): void - { - $validator = $this->makeValidator( - ['items' => [ - [ - 'active_email' => 'user1@example.com', - 'any_email' => 'user3@example.com', - ], - ]], - [ - 'items.*.active_email' => 'required|exists:batch_test_users,email,status,active', - 'items.*.any_email' => 'required|exists:batch_test_users,email', - ], - ); - - $this->assertTrue($validator->passes()); - } - - public function testNonWildcardRuleBlocksBatchingForSameTableColumn(): void - { - $validator = $this->makeValidator( - [ - 'email' => 'user1@example.com', - 'items' => [ - ['email' => 'user1@example.com'], - ['email' => 'user2@example.com'], - ], - ], - [ - 'email' => 'required|exists:batch_test_users,email', - 'items.*.email' => 'required|exists:batch_test_users,email', - ], - ); - - $this->assertTrue($validator->passes()); - } - - public function testArrayFormExistsRuleWithExtraConditionsBlocksBatchingForSameTableColumn(): void - { - $validator = $this->makeValidator( - [ - 'email' => 'user3@example.com', - 'items' => [ - ['email' => 'user1@example.com'], - ['email' => 'user2@example.com'], - ], - ], - [ - 'email' => [['exists', 'batch_test_users', 'email', 'status', 'active']], - 'items.*.email' => 'required|exists:batch_test_users,email', - ], - ); - - $this->assertFalse($validator->passes()); - $this->assertTrue($validator->errors()->has('email')); - $this->assertFalse($validator->errors()->has('items.0.email')); - $this->assertFalse($validator->errors()->has('items.1.email')); - } - - public function testFieldReferenceIgnoreIsNotBatched(): void - { - $validator = $this->makeValidator( - ['items' => [ - ['email' => 'user1@example.com', 'id' => 1], - ['email' => 'user2@example.com', 'id' => 2], - ]], - [ - 'items.*.email' => 'required|unique:batch_test_users,email,[items.*.id]', - ], - ); - - // Should validate correctly via the per-item path (not batched) - $this->assertTrue($validator->passes()); - } - - public function testModelClassRuleResolvesCorrectly(): void - { - $validator = $this->makeValidator( - ['items' => [ - ['email' => 'user1@example.com'], - ['email' => 'user2@example.com'], - ]], - ['items.*.email' => 'required|exists:' . BatchTestUser::class . ',email'], - ); - - $this->assertTrue($validator->passes()); - } - - public function testObjectFormExistsRulesBatchCorrectly(): void - { - $rule = new Exists('batch_test_users', 'email'); - - $validator = $this->makeValidator( - ['items' => [ - ['email' => 'user1@example.com'], - ['email' => 'user2@example.com'], - ]], - ['items.*.email' => ['required', $rule]], - ); - - $this->assertTrue($validator->passes()); - } - - public function testObjectFormUniqueRuleWithIgnoreBatchesCorrectly(): void - { - $rule = (new Unique('batch_test_users', 'email')) - ->ignore(1, 'id'); - - $validator = $this->makeValidator( - ['items' => [ - ['email' => 'user1@example.com'], - ['email' => 'user2@example.com'], - ['email' => 'new@example.com'], - ]], - ['items.*.email' => ['required', $rule]], - ); - - $this->assertFalse($validator->passes()); - $this->assertFalse($validator->errors()->has('items.0.email')); - $this->assertTrue($validator->errors()->has('items.1.email')); - $this->assertFalse($validator->errors()->has('items.2.email')); - } - - public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): void - { - $email = 'slash\id@example.com'; - - $this->app->make('db')->table('batch_test_users')->insert([ - 'email' => $email, - 'status' => 'active', - ]); - - $rule = (string) (new Unique('batch_test_users', 'email')) - ->ignore($email, 'email'); - - $validator = $this->makeValidator( - ['items' => [ - ['email' => $email], - ['email' => 'new@example.com'], - ]], - ['items.*.email' => ['required', $rule]], - ); - - DB::enableQueryLog(); - - try { - $result = $validator->passes(); - $queryLog = DB::getQueryLog(); - } finally { - DB::disableQueryLog(); - } - - $this->assertTrue($result); - - $uniqueQueries = array_filter($queryLog, function ($entry) { - return str_contains($entry['query'], 'batch_test_users'); - }); - - $this->assertCount(1, $uniqueQueries); - } - - public function testArrayFormExistsRuleBlocksBatchingForSameTableColumn(): void - { - $validator = $this->makeValidator( - [ - 'email' => 'user1@example.com', - 'items' => [ - ['email' => 'user1@example.com'], - ['email' => 'user2@example.com'], - ], - ], - [ - 'email' => [['exists', 'batch_test_users', 'email']], - 'items.*.email' => 'required|exists:batch_test_users,email', - ], - ); - - $this->assertTrue($validator->passes()); - } - - public function testNonBatchableStringWildcardRuleDoesNotCorruptResults(): void - { - $validator = $this->makeValidator( - ['items' => [ - ['email' => 'user1@example.com', 'id' => 1], - ['email' => 'user2@example.com', 'id' => 2], - ]], - [ - 'items.*.email' => [ - 'required', - 'exists:batch_test_users,email', - 'unique:batch_test_users,email,[items.*.id]', - ], - ], - ); - - $this->assertTrue($validator->passes()); - } - - public function testObjectFormExistsWithInferredColumnAndDifferentShapeBlocksBatchingForSameTableColumn(): void - { - $rule = (new Exists('batch_test_users')) - ->where('status', 'active'); - - $validator = $this->makeValidator( - [ - 'email' => 'user3@example.com', - 'items' => [ - ['email' => 'user1@example.com'], - ['email' => 'user2@example.com'], - ], - ], - [ - 'email' => ['required', $rule], - 'items.*.email' => 'required|exists:batch_test_users,email', - ], - ); - - $this->assertFalse($validator->passes()); - $this->assertTrue($validator->errors()->has('email')); - $this->assertFalse($validator->errors()->has('items.0.email')); - $this->assertFalse($validator->errors()->has('items.1.email')); - } - - public function testArrayValuedExistsRulesBatchOneDimensionalValues(): void - { - $validator = $this->makeValidator( - ['items' => [ - ['emails' => ['user1@example.com', 'user2@example.com', 'user1@example.com']], - ['emails' => ['user3@example.com', 'missing@example.com']], - ]], - ['items.*.emails' => ['required', 'array', 'exists:batch_test_users,email']], - ); - - $this->assertFalse($validator->passes()); - $this->assertFalse($validator->errors()->has('items.0.emails')); - $this->assertTrue($validator->errors()->has('items.1.emails')); - } - - public function testValidatorAwareMutationDisablesPresencePrecomputation(): void - { - $validator = $this->makeValidator( - ['items' => [['email' => 'user1@example.com']]], - ['items.*.email' => [ - new class implements Rule, ValidatorAwareRule { - private Validator $validator; - - public function setValidator(Validator $validator): static - { - $this->validator = $validator; - - return $this; - } - - public function passes(string $attribute, mixed $value): bool - { - $this->validator->setValue($attribute, 'user2@example.com'); - - return true; - } - - public function message(): string - { - return 'The value could not be prepared.'; - } - }, - 'unique:batch_test_users,email', - ]], - ); - - $this->assertFalse($validator->passes()); - $this->assertTrue($validator->errors()->has('items.0.email')); - } - - private function makeValidator(array $data, array $rules): Validator - { - $translator = new Translator(new ArrayLoader, 'en'); - $validator = new Validator($translator, $data, $rules); - $validator->setPresenceVerifier($this->app->make('validation.presence')); - - return $validator; - } -} - -class BatchTestUser extends Model -{ - protected ?string $table = 'batch_test_users'; -} diff --git a/tests/Validation/BenchmarkValidationCommandTest.php b/tests/Validation/BenchmarkValidationCommandTest.php new file mode 100644 index 0000000000..f2d00d0932 --- /dev/null +++ b/tests/Validation/BenchmarkValidationCommandTest.php @@ -0,0 +1,74 @@ +runCommand([ + '--scenarios' => 'flat', + '--iterations' => '1', + ]); + + $this->assertSame(Command::SUCCESS, $status); + $this->assertStringContainsString('Benchmarking flat', $output); + $this->assertStringContainsString('median of 1 measured iteration', $output); + } + + public function testUnknownScenarioFailsWithoutRunningAnotherWorkload(): void + { + [$status, $output] = $this->runCommand([ + '--scenarios' => 'missing', + '--iterations' => '1', + ]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('Invalid scenario: missing.', $output); + $this->assertStringNotContainsString('Benchmarking', $output); + } + + #[DataProvider('invalidIterationsProvider')] + public function testInvalidIterationsFail(string $iterations): void + { + [$status, $output] = $this->runCommand([ + '--scenarios' => 'flat', + '--iterations' => $iterations, + ]); + + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString("Invalid iterations: {$iterations}.", $output); + $this->assertStringNotContainsString('Benchmarking', $output); + } + + public static function invalidIterationsProvider(): array + { + return [ + 'zero' => ['0'], + 'non-integer' => ['invalid'], + ]; + } + + /** + * Run the benchmark command with the given input. + * + * @return array{0: int, 1: string} + */ + private function runCommand(array $input): array + { + $command = new BenchmarkValidationCommand; + $command->setHypervel($this->app); + $output = new BufferedOutput; + + return [$command->run(new ArrayInput($input), $output), $output->fetch()]; + } +} diff --git a/tests/Validation/ValidationBatchDatabaseCheckerTest.php b/tests/Validation/ValidationBatchDatabaseCheckerTest.php index ba81b5c925..a5e94c4103 100644 --- a/tests/Validation/ValidationBatchDatabaseCheckerTest.php +++ b/tests/Validation/ValidationBatchDatabaseCheckerTest.php @@ -9,82 +9,284 @@ use Hypervel\Validation\DatabasePresenceVerifier; use Hypervel\Validation\PrecomputedPresenceVerifier; use Mockery as m; +use RuntimeException; use stdClass; use Stringable; class ValidationBatchDatabaseCheckerTest extends TestCase { - public function testUsesTheProvidedVerifierWithNormalizedOneDimensionalValues(): void + public function testUsesTheCompleteQueryShapeAndRetainsRawSqlBindings(): void { - $stringable = new class implements Stringable { + $meta = $this->metadata([ + 'connection' => 'named', + 'ignore' => 'ignored', + 'idColumn' => 'uuid', + 'wheres' => ['status' => 'active'], + ]); + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues') + ->once() + ->with('users', 'id', [1, 2.5], 'named', 'ignored', 'uuid', ['status' => 'active']) + ->andReturn(['1', '2.5']); + $presenceVerifier->shouldReceive('getCount') + ->once() + ->with('users', 'id', '1', 'ignored', 'uuid', ['status' => 'active']) + ->andReturn(0); + $presenceVerifier->shouldReceive('setConnection')->once()->with('named'); + + $verifier = BatchDatabaseChecker::buildVerifier($this->batchGroups($meta, [1, 2.5]), $presenceVerifier); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $verifier->setConnection('named'); + $this->assertSame(1, $verifier->getCount('users', 'id', 1, 'ignored', 'uuid', ['status' => 'active'])); + $this->assertSame(1, $verifier->getCount('users', 'id', 2.5, 'ignored', 'uuid', ['status' => 'active'])); + $this->assertSame(0, $verifier->getCount('users', 'id', '1', 'ignored', 'uuid', ['status' => 'active'])); + } + + public function testNormalizesNativeArraysAndDelegatesStringableValuesWithoutCasting(): void + { + $casts = 0; + $stringable = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + public function __toString(): string { + ++$this->casts; + return '3'; } }; + $meta = $this->metadata(); $presenceVerifier = m::mock(DatabasePresenceVerifier::class); $presenceVerifier->shouldReceive('getExistingValues') ->once() - ->with('users', 'id', ['1', '2', '3'], 'named', 'ignored', 'uuid', ['status' => 'active']) - ->andReturn([1, 2, 3]); - - $verifier = BatchDatabaseChecker::buildVerifier([ - 'users' => ['meta' => $this->metadata([ - 'connection' => 'named', - 'ignore' => 'ignored', - 'idColumn' => 'uuid', - 'wheres' => ['status' => 'active'], - ]), 'values' => [[1, 2], 2, $stringable]], - ], $presenceVerifier); + ->with('users', 'id', [1, 2], null, null, null, []) + ->andReturn(['1', '2']); + $presenceVerifier->shouldReceive('getCount') + ->once() + ->with('users', 'id', m::on(static fn (mixed $value): bool => $value === $stringable), null, null, []) + ->andReturn(1); + + $verifier = BatchDatabaseChecker::buildVerifier($this->batchGroups($meta, [[1, 2], 2, $stringable]), $presenceVerifier); $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); - $this->assertSame(3, $verifier->getMultiCount('users', 'id', [1, 2, 3])); + $this->assertSame(2, $verifier->getMultiCount('users', 'id', [1, 2])); + $this->assertSame(1, $verifier->getCount('users', 'id', $stringable)); + $this->assertSame(0, $casts); } - public function testChunksLargeValueSetsBeforeCallingTheVerifier(): void + public function testArrayContainingStringableDelegatesAsAWholeWithoutDisablingSafeSiblings(): void + { + $casts = 0; + $stringable = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + public function __toString(): string + { + ++$this->casts; + + return 'unsafe'; + } + }; + $candidate = ['safe-in-array', $stringable]; + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues') + ->once() + ->with('users', 'id', ['safe-sibling'], null, null, null, []) + ->andReturn(['safe-sibling']); + $presenceVerifier->shouldReceive('getMultiCount') + ->once() + ->with('users', 'id', m::on(static fn (array $values): bool => $values === $candidate), []) + ->andReturn(1); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), [$candidate, 'safe-sibling']), + $presenceVerifier, + ); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', 'safe-sibling')); + $this->assertSame(1, $verifier->getMultiCount('users', 'id', $candidate)); + $this->assertSame(0, $casts); + } + + public function testEqualLookingIntegerAndStringCandidatesRetainBothBindings(): void + { + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues') + ->once() + ->with('users', 'id', ['1', 1], null, null, null, []) + ->andReturn(['1']); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), ['1', 1]), + $presenceVerifier, + ); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', '1')); + $this->assertSame(1, $verifier->getCount('users', 'id', 1)); + $this->assertSame(1, $verifier->getMultiCount('users', 'id', ['1', 1])); + } + + public function testChunksLargeValueSetsAndMarksCrossChunkMultiCountsUncertain(): void { $chunkSizes = []; + $values = range(1, 1001); $presenceVerifier = m::mock(DatabasePresenceVerifier::class); $presenceVerifier->shouldReceive('getExistingValues') ->twice() - ->andReturnUsing(function (string $collection, string $column, array $values) use (&$chunkSizes): array { - $chunkSizes[] = count($values); + ->andReturnUsing(function (string $collection, string $column, array $chunk) use (&$chunkSizes): array { + $chunkSizes[] = count($chunk); - return $values; + return array_map(strval(...), $chunk); }); + $presenceVerifier->shouldReceive('getMultiCount')->once()->with('users', 'id', $values, [])->andReturn(1001); - $verifier = BatchDatabaseChecker::buildVerifier([ - 'users' => ['meta' => $this->metadata(), 'values' => range(1, 1001)], - ], $presenceVerifier); + $verifier = BatchDatabaseChecker::buildVerifier($this->batchGroups($this->metadata(), $values), $presenceVerifier); $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); $this->assertSame([1000, 1], $chunkSizes); + $this->assertSame(1001, $verifier->getMultiCount('users', 'id', $values)); } - public function testUnsupportedNestedValueDeclinesTheWholeGroup(): void + public function testEmptyFirstStageProvesEverySubmittedValueAbsent(): void { $presenceVerifier = m::mock(DatabasePresenceVerifier::class); - $presenceVerifier->shouldNotReceive('getExistingValues'); + $presenceVerifier->shouldReceive('getExistingValues')->once()->andReturn([]); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), ['first', 'second']), + $presenceVerifier, + ); - $this->assertNull(BatchDatabaseChecker::buildVerifier([ - 'users' => ['meta' => $this->metadata(), 'values' => [[1, new stdClass]]], - ], $presenceVerifier)); + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(0, $verifier->getCount('users', 'id', 'first')); + $this->assertSame(0, $verifier->getCount('users', 'id', 'second')); + } + + public function testSoleNonExactDatabaseMatchIsKnownPresentWithoutASecondQuery(): void + { + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues')->once()->with('users', 'id', ['Case'], null, null, null, [])->andReturn(['case']); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), ['Case']), + $presenceVerifier, + ); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', 'Case')); + $this->assertSame(1, $verifier->getMultiCount('users', 'id', ['Case'])); + } + + public function testMultipleNonExactMatchesDelegateWithoutRepeatingTheGroupedQuery(): void + { + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues') + ->once() + ->with('users', 'id', ['Case', 'Other'], null, null, null, []) + ->andReturn(['case', 'other']); + $presenceVerifier->shouldReceive('getCount')->once()->with('users', 'id', 'Case', null, null, [])->andReturn(1); + $presenceVerifier->shouldReceive('getCount')->once()->with('users', 'id', 'Other', null, null, [])->andReturn(1); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), ['Case', 'Other']), + $presenceVerifier, + ); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', 'Case')); + $this->assertSame(1, $verifier->getCount('users', 'id', 'Other')); + } + + public function testSecondStageProvesMissesAbsentAfterAnExactHit(): void + { + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues')->once()->with('users', 'id', ['exact', 'missing'], null, null, null, [])->andReturn(['exact']); + $presenceVerifier->shouldReceive('getExistingValues')->once()->with('users', 'id', ['missing'], null, null, null, [])->andReturn([]); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), ['exact', 'missing']), + $presenceVerifier, + ); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', 'exact')); + $this->assertSame(0, $verifier->getCount('users', 'id', 'missing')); + } + + public function testSecondStageExactMatchesBecomeScalarKnownFacts(): void + { + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues')->once()->with('users', 'id', ['exact', 'Case', 'unknown'], null, null, null, [])->andReturn(['exact', 'case']); + $presenceVerifier->shouldReceive('getExistingValues')->once()->with('users', 'id', ['Case', 'unknown'], null, null, null, [])->andReturn(['Case']); + $presenceVerifier->shouldReceive('getCount')->once()->with('users', 'id', 'unknown', null, null, [])->andReturn(0); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), ['exact', 'Case', 'unknown']), + $presenceVerifier, + ); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', 'Case')); + $this->assertSame(0, $verifier->getCount('users', 'id', 'unknown')); + } + + public function testUnsupportedCandidateDoesNotDisableSafeSiblings(): void + { + $presenceVerifier = m::mock(DatabasePresenceVerifier::class); + $presenceVerifier->shouldReceive('getExistingValues')->once()->with('users', 'id', ['safe'], null, null, null, [])->andReturn(['safe']); + + $verifier = BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), [[1, new stdClass], false, 'safe']), + $presenceVerifier, + ); + + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', 'safe')); } - public function testEmptyValueSetDoesNotCreateALookup(): void + public function testAllUnsupportedOrEmptyCandidatesCreateNoLookup(): void { $presenceVerifier = m::mock(DatabasePresenceVerifier::class); $presenceVerifier->shouldNotReceive('getExistingValues'); - $this->assertNull(BatchDatabaseChecker::buildVerifier([ - 'users' => ['meta' => $this->metadata(), 'values' => []], - ], $presenceVerifier)); + $this->assertNull(BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), [[1, new stdClass], false, true, []]), + $presenceVerifier, + )); + $this->assertNull(BatchDatabaseChecker::buildVerifier( + $this->batchGroups($this->metadata(), []), + $presenceVerifier, + )); + } + + /** + * Build batch groups keyed by the complete query shape. + */ + private function batchGroups(array $meta, array $values): array + { + $lookupKey = PrecomputedPresenceVerifier::lookupKey( + $meta['connection'], + $meta['table'], + $meta['column'], + $meta['ignore'], + $meta['idColumn'], + $meta['wheres'], + ) ?? throw new RuntimeException('Expected batchable metadata.'); + + return [$lookupKey => ['meta' => $meta, 'values' => $values]]; } /** * Build batch metadata. * - * @return array{connection: ?string, table: string, column: string, wheres: array, ignore: mixed, idColumn: string, type: string} + * @return array{connection: ?string, table: string, column: string, wheres: array, ignore: null|int|string, idColumn: ?string} */ private function metadata(array $overrides = []): array { @@ -94,8 +296,7 @@ private function metadata(array $overrides = []): array 'column' => 'id', 'wheres' => [], 'ignore' => null, - 'idColumn' => 'id', - 'type' => 'unique', + 'idColumn' => null, ], $overrides); } } diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index 08f93395c1..131ec3a240 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Validation\ValidationCompiledExecutionTest; +use Brick\Math\Exception\NumberFormatException; use Closure; use DateTimeImmutable; use Hypervel\Contracts\Validation\ImplicitRule; @@ -17,6 +18,7 @@ use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; +use SplFileInfo; use stdClass; use Stringable; @@ -64,6 +66,138 @@ public function testErrorMessagesWithReplacements() $this->assertStringContainsString('3', $v->errors()->first('name')); } + public function testRuntimeDispatchedSizeRulesMatchCompiledAndDelegatedExecution(): void + { + $cases = [ + [['value' => '100'], ['value' => 'max:3'], true], + [['value' => '100'], ['value' => 'numeric|max:3'], false], + [['value' => '2.00'], ['value' => 'max:3|decimal:2'], true], + [['value' => '2.00'], ['value' => 'string|numeric|max:3'], true], + [['value' => [1, 2, 3]], ['value' => 'between:2,3'], true], + [['value' => new SplFileInfo(__FILE__)], ['value' => 'file|min:0|max:1000'], true], + [['value' => 'abc'], ['value' => 'size:3.0000000000000000001'], false], + ]; + + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach ($cases as [$data, $rules, $expected]) { + $validator = $this->makeValidator($data, $rules, validatorClass: $validatorClass); + + $this->assertSame($expected, $validator->passes(), $validatorClass . ' failed for ' . reset($rules)); + } + } + } + + public function testDateFormatRequiresAnExactRoundTripInCompiledAndDelegatedExecution(): void + { + $cases = [ + ['1', 'date_format:m', false], + ['01', 'date_format:m', true], + ['24', 'date_format:Y', false], + ['0024', 'date_format:Y', true], + ['20250101', 'date_format:Ymd', true], + [0, 'date_format:U', true], + ['value', [['date_format', "\0"]], false], + ]; + + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach ($cases as [$value, $rules, $expected]) { + $validator = $this->makeValidator( + ['value' => $value], + ['value' => $rules], + validatorClass: $validatorClass, + ); + + $this->assertSame($expected, $validator->passes(), $validatorClass . ' failed for ' . (string) $value); + } + } + } + + public function testNonNumericValueWithNumericSiblingUsesItsRuntimeShapeBeforeTypeFailure(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $validator = $this->makeValidator( + ['value' => 'abc'], + ['value' => 'min:3|numeric'], + validatorClass: $validatorClass, + ); + + $this->assertFalse($validator->passes()); + $this->assertArrayNotHasKey('Min', $validator->failed()['value']); + $this->assertArrayHasKey('Numeric', $validator->failed()['value']); + } + } + + public function testNumericComparisonStateDoesNotLeakIntoInlineSizeMessages(): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $translator->addLines([ + 'validation.max.numeric' => 'numeric max', + 'validation.max.string' => 'string max', + ], 'en'); + + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $validator = new $validatorClass( + $translator, + ['field' => '123456', 'other' => 2], + ['field' => 'string|gt:other|max:5'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame('string max', $validator->errors()->first('field')); + } + } + + public function testNumericSizeChecksEnforceExponentPolicyExactlyOnce(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $calls = 0; + $validator = $this->makeValidator( + ['value' => '1e2'], + ['value' => 'max:200|numeric'], + validatorClass: $validatorClass, + ); + $validator->ensureExponentWithinAllowedRangeUsing( + function (int $scale, string $attribute, string $value) use (&$calls): bool { + ++$calls; + + return $scale === 2 && $attribute === 'value' && $value === '1e2'; + }, + ); + + $this->assertTrue($validator->passes()); + $this->assertSame(1, $calls); + } + } + + public function testNonFiniteNumericSizesPreserveNumberFormatExceptionWithoutWarnings(): void + { + $cases = [ + [NAN, 'NAN'], + [INF, 'INF'], + [-INF, '-INF'], + ]; + + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach ($cases as [$value, $representation]) { + $validator = $this->makeValidator( + ['value' => $value], + ['value' => 'numeric|max:5'], + validatorClass: $validatorClass, + ); + + try { + $validator->passes(); + $this->fail("{$validatorClass} did not reject {$representation}."); + } catch (NumberFormatException $exception) { + $this->assertSame( + "Value \"{$representation}\" does not represent a valid number.", + $exception->getMessage(), + ); + } + } + } + } + public function testBailStopsOnFirstFailure() { $v = $this->makeValidator(['name' => 123], ['name' => 'bail|string|max:255']); @@ -72,6 +206,28 @@ public function testBailStopsOnFirstFailure() $this->assertCount(1, $v->errors()->get('name')); } + public function testBailUsesPlaceholderCleanedAttributeKeys(): void + { + $validator = $this->makeValidator( + ['literal.dot' => []], + ['literal\.dot' => 'bail|string|integer'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame(['String'], array_keys($validator->failed()['literal.dot'])); + } + + public function testImplicitFailureStopsUsingPlaceholderCleanedAttributeKeys(): void + { + $validator = $this->makeValidator( + ['literal.dot' => 'no'], + ['literal\.dot' => 'accepted|integer'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame(['Accepted'], array_keys($validator->failed()['literal.dot'])); + } + public function testStopOnFirstFailure() { $v = $this->makeValidator( @@ -301,7 +457,187 @@ public function testSubclassWithOverriddenValidateStringIsNotBypassed() $this->assertFalse($v->passes()); } - public function testPreOptimizationGuardSkipsWithCustomExtensions() + public function testSubclassMetaRulesAndStopHookRemainDelegated(): void + { + $validator = $this->makeValidator( + ['name' => 'value'], + ['name' => 'nullable|bail|sometimes|string'], + validatorClass: ValidationHookTrackingValidator::class, + ); + + $this->assertTrue($validator->passes()); + $this->assertSame(1, $validator->nullableCalls); + $this->assertSame(1, $validator->bailCalls); + $this->assertSame(1, $validator->sometimesCalls); + $this->assertGreaterThan(0, $validator->optionalCheckCalls); + $this->assertSame(4, $validator->stopCalls); + } + + public function testSubclassStopHookCanStopAfterItsFirstFailure(): void + { + $validator = $this->makeValidator( + ['name' => 'invalid'], + ['name' => 'integer|string'], + validatorClass: ValidationHookTrackingValidator::class, + ); + $validator->alwaysStop = true; + + $this->assertFalse($validator->passes()); + $this->assertSame(1, $validator->stopCalls); + $this->assertSame(['Integer'], array_keys($validator->failed()['name'])); + } + + public function testSubclassOptionalHookControlsAbsentSometimesAttribute(): void + { + $validator = $this->makeValidator( + [], + ['name' => 'sometimes|required'], + validatorClass: ValidationHookTrackingValidator::class, + ); + $validator->forceOptional = true; + + $this->assertFalse($validator->passes()); + $this->assertGreaterThan(0, $validator->optionalCheckCalls); + $this->assertArrayHasKey('Required', $validator->failed()['name']); + } + + public function testNonScalarParametersAreNotEvaluatedAfterBailStopsTheAttribute(): void + { + $casts = 0; + $parameter = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + public function __toString(): string + { + ++$this->casts; + + return 'allowed'; + } + }; + $validator = $this->makeValidator( + ['value' => 'invalid'], + ['value' => ['bail', 'integer', ['in', $parameter]]], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame(0, $casts); + $this->assertSame(['Integer'], array_keys($validator->failed()['value'])); + } + + public function testReachedNonScalarDateFormatParameterIsCastOnce(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $casts = 0; + $parameter = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + public function __toString(): string + { + ++$this->casts; + + return 'Ymd'; + } + }; + $validator = $this->makeValidator( + ['value' => '20250101'], + ['value' => [['date_format', $parameter]]], + validatorClass: $validatorClass, + ); + + $this->assertTrue($validator->passes(), $validatorClass); + $this->assertSame(1, $casts, $validatorClass); + } + } + + public function testEscapedDotPresenceRulesSkipAfterAnEarlierFailure(): void + { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach (['exists:users,email', 'unique:users,email'] as $presenceRule) { + $calls = 0; + $presenceVerifier = new class($calls) implements PresenceVerifierInterface { + public function __construct(private int &$calls) + { + } + + public function getCount(string $collection, string $column, mixed $value, int|string|null $excludeId = null, ?string $idColumn = null, array $extra = []): int + { + ++$this->calls; + + return 0; + } + + public function getMultiCount(string $collection, string $column, array $values, array $extra = []): int + { + ++$this->calls; + + return 0; + } + }; + $validator = $this->makeValidator( + ['items' => [['literal.dot' => 'invalid']]], + ['items.*.literal\.dot' => "integer|{$presenceRule}"], + validatorClass: $validatorClass, + ); + $validator->setPresenceVerifier($presenceVerifier); + + $this->assertFalse($validator->passes()); + $this->assertSame(0, $calls); + $this->assertTrue($validator->errors()->has('items.0.literal.dot')); + } + } + } + + public function testStringablePresenceCandidateIsNotCastWhenAnEarlierRuleFails(): void + { + $casts = 0; + $value = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + public function __toString(): string + { + ++$this->casts; + + return 'user1@example.com'; + } + }; + $presenceCalls = 0; + $presenceVerifier = new class($presenceCalls) implements PresenceVerifierInterface { + public function __construct(private int &$presenceCalls) + { + } + + public function getCount(string $collection, string $column, mixed $value, int|string|null $excludeId = null, ?string $idColumn = null, array $extra = []): int + { + ++$this->presenceCalls; + + return 1; + } + + public function getMultiCount(string $collection, string $column, array $values, array $extra = []): int + { + ++$this->presenceCalls; + + return 1; + } + }; + $validator = $this->makeValidator( + ['items' => [['email' => $value]]], + ['items.*.email' => 'array|exists:users,email'], + ); + $validator->setPresenceVerifier($presenceVerifier); + + $this->assertFalse($validator->passes()); + $this->assertSame(0, $casts); + $this->assertSame(0, $presenceCalls); + } + + public function testUnusedCustomExtensionPreservesExclusionBehavior(): void { $v = $this->makeValidator( ['type' => 'section', 'details' => 'test'], @@ -466,6 +802,25 @@ public static function guardedRuleCases(): iterable yield 'not in accepts backed enum value' => ['not_in:active', MembershipStatus::Active, true]; } + public function testJsonRejectsResourcesInCompiledAndDelegatedExecution(): void + { + $resource = fopen('php://memory', 'r'); + + try { + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + $validator = $this->makeValidator( + ['value' => $resource], + ['value' => 'json'], + validatorClass: $validatorClass, + ); + + $this->assertFalse($validator->passes()); + } + } finally { + fclose($resource); + } + } + public function testMembershipRulesMatchInCompiledAndDelegatedExecution(): void { foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { @@ -866,6 +1221,58 @@ class DelegatedValidationValidator extends Validator { } +class ValidationHookTrackingValidator extends Validator +{ + public int $nullableCalls = 0; + + public int $bailCalls = 0; + + public int $sometimesCalls = 0; + + public int $optionalCheckCalls = 0; + + public int $stopCalls = 0; + + public bool $alwaysStop = false; + + public bool $forceOptional = false; + + public function validateNullable(): bool + { + ++$this->nullableCalls; + + return true; + } + + public function validateBail(): bool + { + ++$this->bailCalls; + + return true; + } + + public function validateSometimes(): bool + { + ++$this->sometimesCalls; + + return true; + } + + protected function passesOptionalCheck(string $attribute): bool + { + ++$this->optionalCheckCalls; + + return $this->forceOptional || parent::passesOptionalCheck($attribute); + } + + protected function shouldStopValidating(string $attribute): bool + { + ++$this->stopCalls; + + return $this->alwaysStop || parent::shouldStopValidating($attribute); + } +} + class ValidationStringableValue implements Stringable { public function __construct(private readonly string $value) diff --git a/tests/Validation/ValidationDatabasePresenceVerifierTest.php b/tests/Validation/ValidationDatabasePresenceVerifierTest.php index a4583e6015..8d406dfd25 100644 --- a/tests/Validation/ValidationDatabasePresenceVerifierTest.php +++ b/tests/Validation/ValidationDatabasePresenceVerifierTest.php @@ -78,7 +78,7 @@ public function testGetCountWithValidExcludeId(): void } #[DataProvider('connections')] - public function testGetExistingValuesUsesRequestedConnectionAndQueryShape(?string $connection): void + public function testGetExistingValuesUsesRequestedConnectionAndReturnsDistinctValues(?string $connection): void { $verifier = new DatabasePresenceVerifier($db = m::mock(ConnectionResolverInterface::class)); $verifier->setConnection('stateful-connection'); @@ -89,6 +89,7 @@ public function testGetExistingValuesUsesRequestedConnectionAndQueryShape(?strin $builder->shouldReceive('where')->once()->with('uuid', '<>', 'ignored')->andReturnSelf(); $builder->shouldReceive('whereNull')->once()->with('deleted_at'); $builder->shouldReceive('where')->once()->with('status', 'active'); + $builder->shouldReceive('distinct')->once()->andReturnSelf(); $builder->shouldReceive('pluck')->once()->with('column')->andReturn(new Collection(['first'])); $this->assertSame(['first'], $verifier->getExistingValues( diff --git a/tests/Validation/ValidationPlanExecutorTest.php b/tests/Validation/ValidationPlanExecutorTest.php index 6fa07b0774..d3a72932d4 100644 --- a/tests/Validation/ValidationPlanExecutorTest.php +++ b/tests/Validation/ValidationPlanExecutorTest.php @@ -9,10 +9,11 @@ use Hypervel\Translation\ArrayLoader; use Hypervel\Translation\Translator; use Hypervel\Validation\Enums\CheckType; -use Hypervel\Validation\Enums\SizeMode; use Hypervel\Validation\InlineCheck; use Hypervel\Validation\RulePlan\ExposedExecutorValidator; use PHPUnit\Framework\Attributes\DataProvider; +use SplFileInfo; +use Stringable; class ValidationPlanExecutorTest extends TestCase { @@ -96,6 +97,136 @@ public function testJsonCheckUsesTheSupportNestingLimit(): void )); } + public function testJsonCheckAcceptsStringableValuesAndRejectsResources(): void + { + $validator = $this->makeValidator(); + $check = new InlineCheck(CheckType::Json); + $stringable = new class implements Stringable { + public function __toString(): string + { + return '{"valid":true}'; + } + }; + $resource = fopen('php://memory', 'r'); + + try { + $this->assertTrue($validator->publicExecuteInline($check, $stringable, 'field')); + $this->assertFalse($validator->publicExecuteInline($check, $resource, 'field')); + } finally { + fclose($resource); + } + } + + public function testPreflightSafetyPartitionCoversEveryInlineCheckType(): void + { + $safe = [ + CheckType::TypeString, CheckType::TypeNumeric, CheckType::TypeInteger, + CheckType::TypeIntegerStrict, CheckType::TypeBoolean, CheckType::TypeArray, + CheckType::Email, CheckType::Url, CheckType::Ip, CheckType::Ipv4, + CheckType::Ipv6, CheckType::Uuid, CheckType::Ulid, CheckType::Json, + CheckType::Ascii, CheckType::HexColor, CheckType::MacAddress, + CheckType::Alpha, CheckType::AlphaAscii, CheckType::AlphaDash, + CheckType::AlphaDashAscii, CheckType::AlphaNum, CheckType::AlphaNumAscii, + CheckType::Lowercase, CheckType::Uppercase, + CheckType::SizeMin, CheckType::SizeMax, CheckType::SizeBetween, + CheckType::SizeExact, CheckType::Digits, CheckType::DigitsBetween, + CheckType::MinDigits, CheckType::MaxDigits, + CheckType::StartsWith, CheckType::EndsWith, CheckType::DoesntStartWith, + CheckType::DoesntEndWith, CheckType::In, CheckType::NotIn, + CheckType::IsDate, CheckType::DateFormat, + ]; + $unsafe = [ + CheckType::Regex, CheckType::NotRegex, CheckType::DateAfter, + CheckType::DateBefore, CheckType::DateAfterOrEq, CheckType::DateBeforeOrEq, + CheckType::DateEquals, CheckType::MultipleOf, + ]; + $reviewedNames = array_map( + static fn (CheckType $type): string => $type->name, + [...$safe, ...$unsafe], + ); + + $this->assertCount(count(array_unique($reviewedNames)), $reviewedNames); + $this->assertEqualsCanonicalizing( + array_map(static fn (CheckType $type): string => $type->name, CheckType::cases()), + $reviewedNames, + ); + + $validator = $this->makeValidator(); + + foreach ($safe as $type) { + $param = in_array($type, [ + CheckType::SizeMin, + CheckType::SizeMax, + CheckType::SizeBetween, + CheckType::SizeExact, + ], true) ? ['numeric' => false] : null; + + $this->assertTrue($validator->publicCanPreflightInline(new InlineCheck($type, $param), 'value')); + } + + foreach ($unsafe as $type) { + $this->assertFalse($validator->publicCanPreflightInline(new InlineCheck($type), 'value')); + } + } + + public function testPreflightRejectsObjectsAndResourcesWithoutInvokingThem(): void + { + $validator = $this->makeValidator(); + $stringable = new class implements Stringable { + public int $casts = 0; + + public function __toString(): string + { + ++$this->casts; + + return 'value'; + } + }; + $file = new class(__FILE__) extends SplFileInfo { + public int $reads = 0; + + public function getSize(): int|false + { + ++$this->reads; + + return parent::getSize(); + } + }; + $resource = fopen('php://memory', 'r'); + + try { + $this->assertFalse($validator->publicCanPreflightInline( + new InlineCheck(CheckType::TypeString), + $stringable, + )); + $this->assertFalse($validator->publicCanPreflightInline( + new InlineCheck(CheckType::SizeMax, ['numeric' => false]), + $file, + )); + $this->assertFalse($validator->publicCanPreflightInline( + new InlineCheck(CheckType::TypeString), + $resource, + )); + $this->assertSame(0, $stringable->casts); + $this->assertSame(0, $file->reads); + } finally { + fclose($resource); + } + } + + public function testPreflightAllowsArraysButRejectsUnsafeNumericSizeValues(): void + { + $validator = $this->makeValidator(); + $arraySize = new InlineCheck(CheckType::SizeMax, ['numeric' => false]); + $numericSize = new InlineCheck(CheckType::SizeMax, ['numeric' => true]); + + $this->assertTrue($validator->publicCanPreflightInline($arraySize, ['value'])); + $this->assertTrue($validator->publicCanPreflightInline($numericSize, '100')); + $this->assertFalse($validator->publicCanPreflightInline($numericSize, '1e2')); + $this->assertFalse($validator->publicCanPreflightInline($numericSize, INF)); + $this->assertFalse($validator->publicCanPreflightInline($numericSize, NAN)); + } + #[DataProvider('charClassCases')] public function testCharacterClassChecks(CheckType $type, mixed $value, bool $expected) { @@ -121,28 +252,38 @@ public static function charClassCases(): iterable yield 'Uppercase fails' => [CheckType::Uppercase, 'Hello', false]; } - public function testSizeMinWithStringMode() + public function testSizeMinWithStringValue(): void { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeMin, ['n' => '3', 'mode' => SizeMode::String]); + $check = new InlineCheck(CheckType::SizeMin, [ + 'numeric' => false, + 'threshold' => ['raw' => '3', 'integer' => 3], + ]); $this->assertTrue($validator->publicExecuteInline($check, 'hello', 'field')); $this->assertFalse($validator->publicExecuteInline($check, 'hi', 'field')); } - public function testSizeMaxWithNumericMode() + public function testSizeMaxWithNumericSemantics(): void { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeMax, ['n' => '100', 'mode' => SizeMode::Numeric]); + $check = new InlineCheck(CheckType::SizeMax, [ + 'numeric' => true, + 'threshold' => ['raw' => '100', 'integer' => 100], + ]); $this->assertTrue($validator->publicExecuteInline($check, '50', 'field')); $this->assertFalse($validator->publicExecuteInline($check, '150', 'field')); } - public function testSizeBetweenWithArrayMode() + public function testSizeBetweenWithArrayValue(): void { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeBetween, ['min' => '1', 'max' => '3', 'mode' => SizeMode::Array]); + $check = new InlineCheck(CheckType::SizeBetween, [ + 'numeric' => false, + 'minimum' => ['raw' => '1', 'integer' => 1], + 'maximum' => ['raw' => '3', 'integer' => 3], + ]); $this->assertTrue($validator->publicExecuteInline($check, [1, 2], 'field')); $this->assertFalse($validator->publicExecuteInline($check, [1, 2, 3, 4], 'field')); @@ -302,26 +443,33 @@ public function testMultipleOf() $this->assertFalse($validator->publicExecuteInline($check, 7, 'field')); } - // --- Native size comparison tests --- - public function testSizeComparisonWithIntegerThreshold() { $validator = $this->makeValidator(); - $stringMax = new InlineCheck(CheckType::SizeMax, ['n' => '5', 'mode' => SizeMode::String]); + $stringMax = new InlineCheck(CheckType::SizeMax, [ + 'numeric' => false, + 'threshold' => ['raw' => '5', 'integer' => 5], + ]); $this->assertTrue($validator->publicExecuteInline($stringMax, 'hello', 'field')); $this->assertFalse($validator->publicExecuteInline($stringMax, 'hello!', 'field')); - $arrayMin = new InlineCheck(CheckType::SizeMin, ['n' => '2', 'mode' => SizeMode::Array]); + $arrayMin = new InlineCheck(CheckType::SizeMin, [ + 'numeric' => false, + 'threshold' => ['raw' => '2', 'integer' => 2], + ]); $this->assertTrue($validator->publicExecuteInline($arrayMin, [1, 2], 'field')); $this->assertFalse($validator->publicExecuteInline($arrayMin, [1], 'field')); } - public function testSizeComparisonWithDecimalThresholdUsesNativeComparison() + public function testSizeComparisonWithDecimalThresholdUsesExactComparison(): void { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeMax, ['n' => '3.5', 'mode' => SizeMode::String]); + $check = new InlineCheck(CheckType::SizeMax, [ + 'numeric' => false, + 'threshold' => ['raw' => '3.5', 'integer' => null], + ]); $this->assertTrue($validator->publicExecuteInline($check, 'abc', 'field')); $this->assertFalse($validator->publicExecuteInline($check, 'abcd', 'field')); } @@ -330,7 +478,10 @@ public function testSizeExactWithDecimalThresholdRejectsIntegerSize() { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeExact, ['n' => '3.5', 'mode' => SizeMode::String]); + $check = new InlineCheck(CheckType::SizeExact, [ + 'numeric' => false, + 'threshold' => ['raw' => '3.5', 'integer' => null], + ]); $this->assertFalse($validator->publicExecuteInline($check, 'abc', 'field')); } @@ -338,36 +489,50 @@ public function testSizeBetweenWithIntegerThresholds() { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeBetween, ['min' => '2', 'max' => '5', 'mode' => SizeMode::String]); + $check = new InlineCheck(CheckType::SizeBetween, [ + 'numeric' => false, + 'minimum' => ['raw' => '2', 'integer' => 2], + 'maximum' => ['raw' => '5', 'integer' => 5], + ]); $this->assertTrue($validator->publicExecuteInline($check, 'hi', 'field')); $this->assertTrue($validator->publicExecuteInline($check, 'hello', 'field')); $this->assertFalse($validator->publicExecuteInline($check, 'h', 'field')); $this->assertFalse($validator->publicExecuteInline($check, 'helloo', 'field')); } - public function testSizeBetweenWithDecimalThresholdUsesNativeComparison() + public function testSizeBetweenWithDecimalThresholdUsesExactComparison(): void { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeBetween, ['min' => '1', 'max' => '3.5', 'mode' => SizeMode::String]); + $check = new InlineCheck(CheckType::SizeBetween, [ + 'numeric' => false, + 'minimum' => ['raw' => '1', 'integer' => 1], + 'maximum' => ['raw' => '3.5', 'integer' => null], + ]); $this->assertTrue($validator->publicExecuteInline($check, 'abc', 'field')); $this->assertFalse($validator->publicExecuteInline($check, 'abcd', 'field')); } - public function testNumericModeSizeStillUsesBigNumber() + public function testNumericSizeUsesBigNumber(): void { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeMax, ['n' => '100', 'mode' => SizeMode::Numeric]); + $check = new InlineCheck(CheckType::SizeMax, [ + 'numeric' => true, + 'threshold' => ['raw' => '100', 'integer' => 100], + ]); $this->assertTrue($validator->publicExecuteInline($check, '50', 'field')); $this->assertFalse($validator->publicExecuteInline($check, '150', 'field')); } - public function testSizeExactWithArrayMode() + public function testSizeExactWithArrayValue(): void { $validator = $this->makeValidator(); - $check = new InlineCheck(CheckType::SizeExact, ['n' => '3', 'mode' => SizeMode::Array]); + $check = new InlineCheck(CheckType::SizeExact, [ + 'numeric' => false, + 'threshold' => ['raw' => '3', 'integer' => 3], + ]); $this->assertTrue($validator->publicExecuteInline($check, [1, 2, 3], 'field')); $this->assertFalse($validator->publicExecuteInline($check, [1, 2], 'field')); } @@ -390,6 +555,11 @@ private function makeValidator(): ExposedExecutorValidator */ class ExposedExecutorValidator extends Validator { + public function publicCanPreflightInline(InlineCheck $check, mixed $value): bool + { + return $this->canPreflightInline($check, $value); + } + public function publicExecuteInline(InlineCheck $check, mixed $value, string $attribute): bool { return $this->executeInline($check, $value, $attribute); diff --git a/tests/Validation/ValidationPreEvaluatedExclusionsTest.php b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php index 8538eb3128..2c101f0d33 100644 --- a/tests/Validation/ValidationPreEvaluatedExclusionsTest.php +++ b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php @@ -4,15 +4,20 @@ namespace Hypervel\Tests\Validation; +use Closure; use Hypervel\Contracts\Validation\Rule as RuleContract; +use Hypervel\Contracts\Validation\ValidationRule; +use Hypervel\Contracts\Validation\ValidatorAwareRule; use Hypervel\Tests\TestCase; use Hypervel\Translation\ArrayLoader; use Hypervel\Translation\Translator; use Hypervel\Validation\Validator; +use InvalidArgumentException; +use Stringable; class ValidationPreEvaluatedExclusionsTest extends TestCase { - public function testExcludeUnlessRemovesAttributeWhenConditionNotMet() + public function testExcludeUnlessRemovesAttributeWhenConditionNotMet(): void { $v = $this->makeValidator( ['type' => 'section', 'details' => 'some details'], @@ -23,7 +28,7 @@ public function testExcludeUnlessRemovesAttributeWhenConditionNotMet() $this->assertArrayNotHasKey('details', $v->validated()); } - public function testExcludeUnlessKeepsAttributeWhenConditionMet() + public function testExcludeUnlessKeepsAttributeWhenConditionMet(): void { $v = $this->makeValidator( ['type' => 'chapter', 'details' => 'some details'], @@ -34,7 +39,7 @@ public function testExcludeUnlessKeepsAttributeWhenConditionMet() $this->assertArrayHasKey('details', $v->validated()); } - public function testExcludeIfRemovesAttributeWhenConditionMet() + public function testExcludeIfRemovesAttributeWhenConditionMet(): void { $v = $this->makeValidator( ['type' => 'draft', 'publish_date' => '2025-01-01'], @@ -45,7 +50,7 @@ public function testExcludeIfRemovesAttributeWhenConditionMet() $this->assertArrayNotHasKey('publish_date', $v->validated()); } - public function testExcludeIfKeepsAttributeWhenConditionNotMet() + public function testExcludeIfKeepsAttributeWhenConditionNotMet(): void { $v = $this->makeValidator( ['type' => 'published', 'publish_date' => '2025-01-01'], @@ -56,26 +61,59 @@ public function testExcludeIfKeepsAttributeWhenConditionNotMet() $this->assertArrayHasKey('publish_date', $v->validated()); } - public function testExcludeUnlessWithWildcardConditionField() + public function testActiveExclusionsResetBetweenPasses(): void + { + $validator = $this->makeValidator( + ['flag' => 'yes', 'first' => 'first value', 'second' => 'second value'], + [ + 'flag' => 'required|string', + 'first' => 'exclude_if:flag,yes|required|string', + 'second' => 'exclude_if:flag,no|required|string', + ], + ); + + $this->assertTrue($validator->passes()); + $this->assertSame( + ['flag' => 'yes', 'second' => 'second value'], + $validator->getData(), + ); + + $validator->setData([ + 'flag' => 'no', + 'first' => 'first value', + 'second' => 'second value', + ]); + + $this->assertTrue($validator->passes()); + $this->assertSame( + ['flag' => 'no', 'first' => 'first value'], + $validator->getData(), + ); + } + + public function testExcludeUnlessWithWildcardConditionField(): void { $v = $this->makeValidator( ['items' => [ - ['type' => 'chapter', 'position' => 5], - ['type' => 'section', 'position' => 10], + ['type' => 'chapter', 'position' => 5, 'label' => 'First'], + ['type' => 'section', 'position' => 10, 'label' => 'Second'], ]], [ 'items.*.type' => 'required|string', 'items.*.position' => 'exclude_unless:items.*.type,chapter|required|integer', + 'items.*.label' => 'exclude_unless:items.*.type,chapter|required|string', ], ); $this->assertTrue($v->passes()); $validated = $v->validated(); $this->assertArrayHasKey('position', $validated['items'][0]); + $this->assertArrayHasKey('label', $validated['items'][0]); $this->assertArrayNotHasKey('position', $validated['items'][1]); + $this->assertArrayNotHasKey('label', $validated['items'][1]); } - public function testSafetySkipForBooleanConditionField() + public function testBooleanConditionMatchesExecutionSemantics(): void { $v = $this->makeValidator( ['active' => true, 'details' => 'some details'], @@ -86,7 +124,7 @@ public function testSafetySkipForBooleanConditionField() $this->assertArrayHasKey('details', $v->validated()); } - public function testSafetySkipForNullConditionValue() + public function testNullConditionMatchesExecutionSemantics(): void { $v = $this->makeValidator( ['type' => null, 'details' => 'some details'], @@ -94,32 +132,157 @@ public function testSafetySkipForNullConditionValue() ); $v->passes(); - // Should be excluded because type is null and the value matches 'null' sentinel $this->assertArrayNotHasKey('details', $v->validated()); } - public function testGuardSkipsPrePassWithCustomExtensions() + public function testLaterExclusionDoesNotEraseAnEarlierFailure(): void { $v = $this->makeValidator( - ['type' => 'section', 'details' => 'test'], - ['type' => 'required|string', 'details' => 'exclude_unless:type,chapter|required|string'], + ['type' => 'draft', 'publish_date' => 'not-an-integer'], + ['publish_date' => 'integer|exclude_if:type,draft'], + ); + + $this->assertFalse($v->passes()); + $this->assertTrue($v->errors()->has('publish_date')); + $this->assertArrayHasKey('Integer', $v->failed()['publish_date']); + } + + public function testFirstPositionUnconditionalExcludeRemovesTheAttribute(): void + { + $v = $this->makeValidator( + ['secret' => 'value'], + ['secret' => 'exclude|required|string'], + ); + + $this->assertTrue($v->passes()); + $this->assertArrayNotHasKey('secret', $v->validated()); + } + + public function testFirstPositionExclusionSupportsTopLevelNumericAttributes(): void + { + $v = $this->makeValidator( + [0 => 'value'], + [0 => 'exclude|required|string'], + ); + + $this->assertTrue($v->passes()); + $this->assertSame([], $v->validated()); + } + + public function testFirstPositionExcludeWithRemovesTheAttribute(): void + { + $v = $this->makeValidator( + ['trigger' => true, 'details' => 'value'], + ['details' => 'exclude_with:trigger|required|string'], + ); + + $this->assertTrue($v->passes()); + $this->assertArrayNotHasKey('details', $v->validated()); + } + + public function testFirstPositionExcludeWithoutRemovesTheAttribute(): void + { + $v = $this->makeValidator( + ['details' => 'value'], + ['details' => 'exclude_without:trigger|required|string'], + ); + + $this->assertTrue($v->passes()); + $this->assertArrayNotHasKey('details', $v->validated()); + } + + public function testPlanFlagsDoNotDisplaceTheFirstExecutableExclusion(): void + { + $v = $this->makeValidator( + ['type' => 'draft', 'details' => 'value'], + ['details' => 'bail|nullable|sometimes|exclude_if:type,draft|required|string'], + ); + + $this->assertTrue($v->passes()); + $this->assertArrayNotHasKey('details', $v->validated()); + } + + public function testMalformedExclusionIsDeferredUntilNormalExecution(): void + { + $v = $this->makeValidator( + ['first' => 'invalid', 'details' => 'value'], + [ + 'first' => 'integer', + 'details' => 'exclude_if:type|required|string', + ], + )->stopOnFirstFailure(); + + $this->assertFalse($v->passes()); + $this->assertTrue($v->errors()->has('first')); + } + + public function testMalformedExclusionStillThrowsWhenExecutionReachesIt(): void + { + $v = $this->makeValidator( + ['details' => 'value'], + ['details' => 'exclude_if:type|required|string'], + ); + + $this->expectException(InvalidArgumentException::class); + + $v->passes(); + } + + public function testMalformedWildcardExclusionDoesNotOverrideAnEarlierStop(): void + { + $v = $this->makeValidator( + ['first' => 'invalid', 'items' => [['details' => 'value']]], + [ + 'first' => 'integer', + 'items.*.details' => 'exclude_if:groups.*.*.type,chapter|required|string', + ], + )->stopOnFirstFailure(); + + $this->assertFalse($v->passes()); + $this->assertTrue($v->errors()->has('first')); + } + + public function testLiteralNumericSegmentsAreNotMistakenForWildcardCaptures(): void + { + $v = $this->makeValidator( + ['data' => [5 => ['items' => [['type' => 'section', 'value' => 'invalid']]]]], + ['data.5.items.*.value' => 'exclude_unless:data.5.items.*.type,chapter|required|integer'], + ); + + $this->assertTrue($v->passes()); + $this->assertSame([], $v->validated()); + } + + public function testUnusedCustomExtensionDoesNotDisablePreEvaluation(): void + { + $v = $this->makeValidator( + ['type' => 'section', 'appointments' => [['name' => 123]]], + [ + 'appointments' => 'exclude_unless:type,chapter|required|array', + 'appointments.*.name' => 'required|string', + ], ); - $v->addExtension('custom_rule', function () { + $v->addExtension('unused', function (): bool { return true; }); - // Pre-pass is skipped (extensions present), but exclude_unless still - // works correctly via the DelegatedCheck path. $this->assertTrue($v->passes()); - $this->assertArrayNotHasKey('details', $v->validated()); + $this->assertArrayNotHasKey('appointments', $v->validated()); } - public function testGuardSkipsPrePassWithRuleObjects() + public function testPlainRuleObjectDoesNotDisablePreEvaluation(): void { - $customRule = new class implements RuleContract { + $calls = 0; + $customRule = new class($calls) implements RuleContract { + public function __construct(private int &$calls) + { + } + public function passes(string $attribute, mixed $value): bool { + ++$this->calls; + return true; } @@ -130,21 +293,147 @@ public function message(): array|string }; $v = $this->makeValidator( - ['type' => 'section', 'name' => 'test', 'details' => 'test'], + ['type' => 'section', 'appointments' => [['name' => 'test']]], [ - 'type' => 'required|string', - 'name' => [$customRule], + 'appointments' => 'exclude_unless:type,chapter|required|array', + 'appointments.*.name' => [$customRule], + ], + ); + + $this->assertTrue($v->passes()); + $this->assertSame(0, $calls); + } + + public function testPlainModernRuleDoesNotDisablePreEvaluation(): void + { + $calls = 0; + $customRule = new class($calls) implements ValidationRule { + public function __construct(private int &$calls) + { + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + ++$this->calls; + } + }; + + $v = $this->makeValidator( + ['type' => 'section', 'appointments' => [['name' => 'test']]], + [ + 'appointments' => 'exclude_unless:type,chapter|required|array', + 'appointments.*.name' => [$customRule], + ], + ); + + $this->assertTrue($v->passes()); + $this->assertSame(0, $calls); + } + + public function testUsedExtensionDefersExclusionUntilAfterItsMutation(): void + { + $v = $this->makeValidator( + ['prepare' => true, 'type' => 'section', 'details' => 'value'], + [ + 'prepare' => 'prepare_type', 'details' => 'exclude_unless:type,chapter|required|string', ], ); + $v->addExtension( + 'prepare_type', + function (string $attribute, mixed $value, array $parameters, Validator $validator): bool { + $validator->setValue('type', 'chapter'); + + return true; + }, + ); - // Pre-pass is skipped (rule objects present), but exclude_unless still - // works correctly via the DelegatedCheck path. $this->assertTrue($v->passes()); - $this->assertArrayNotHasKey('details', $v->validated()); + $this->assertArrayHasKey('details', $v->validated()); + } + + public function testClosureRuleDefersExclusionUntilAfterItsMutation(): void + { + $v = $this->makeValidator( + ['prepare' => true, 'type' => 'section', 'details' => 'value'], + [ + 'prepare' => [function (string $attribute, mixed $value, Closure $fail, Validator $validator): void { + $validator->setValue('type', 'chapter'); + }], + 'details' => 'exclude_unless:type,chapter|required|string', + ], + ); + + $this->assertTrue($v->passes()); + $this->assertArrayHasKey('details', $v->validated()); + } + + public function testDirectValidatorAwareRuleDefersExclusionUntilAfterItsMutation(): void + { + $customRule = new class implements RuleContract, ValidatorAwareRule { + private Validator $validator; + + public function setValidator(Validator $validator): static + { + $this->validator = $validator; + + return $this; + } + + public function passes(string $attribute, mixed $value): bool + { + $this->validator->setValue('type', 'chapter'); + + return true; + } + + public function message(): string + { + return ''; + } + }; + $v = $this->makeValidator( + ['prepare' => true, 'type' => 'section', 'details' => 'value'], + [ + 'prepare' => [$customRule], + 'details' => 'exclude_unless:type,chapter|required|string', + ], + ); + + $this->assertTrue($v->passes()); + $this->assertArrayHasKey('details', $v->validated()); + } + + public function testWrappedValidatorAwareRuleDefersExclusionUntilAfterItsMutation(): void + { + $customRule = new class implements ValidationRule, ValidatorAwareRule { + private Validator $validator; + + public function setValidator(Validator $validator): static + { + $this->validator = $validator; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $this->validator->setValue('type', 'chapter'); + } + }; + $v = $this->makeValidator( + ['prepare' => true, 'type' => 'section', 'details' => 'value'], + [ + 'prepare' => [$customRule], + 'details' => 'exclude_unless:type,chapter|required|string', + ], + ); + + $this->assertTrue($v->passes()); + $this->assertArrayHasKey('details', $v->validated()); } - public function testMultipleExcludeConditionsOnDifferentAttributes() + public function testMultipleExcludeConditionsOnDifferentAttributes(): void { $v = $this->makeValidator( [ @@ -165,7 +454,7 @@ public function testMultipleExcludeConditionsOnDifferentAttributes() $this->assertArrayNotHasKey('field_b', $validated); } - public function testPreExcludedWildcardAttributesRemovedFromValidatedOutput() + public function testPreExcludedWildcardAttributesRemovedFromValidatedOutput(): void { $items = []; for ($i = 0; $i < 50; ++$i) { @@ -192,7 +481,7 @@ public function testPreExcludedWildcardAttributesRemovedFromValidatedOutput() $this->assertArrayNotHasKey('detail', $validated['items'][2]); } - public function testPreExcludedParentExcludesDescendantAttributes() + public function testPreExcludedParentExcludesDescendantAttributes(): void { $v = $this->makeValidator( [ @@ -214,12 +503,207 @@ public function testPreExcludedParentExcludesDescendantAttributes() $this->assertArrayNotHasKey('appointments', $validated); } - private function makeValidator(array $data, array $rules): Validator + public function testExecutionTimeParentExclusionSkipsLaterDescendants(): void + { + foreach ([Validator::class, DelegatedExclusionValidator::class] as $validatorClass) { + $validator = $this->makeValidator( + ['parent' => ['child' => 'invalid'], 'flag' => 'yes'], + [ + 'parent' => 'array|exclude_if:flag,yes', + 'parent.child' => 'integer', + ], + $validatorClass, + ); + + $this->assertTrue($validator->passes(), $validatorClass); + $this->assertSame([], $validator->errors()->toArray(), $validatorClass); + $this->assertSame(['flag' => 'yes'], $validator->getData(), $validatorClass); + } + } + + public function testDescendantBeforeParentKeepsItsEarlierFailure(): void + { + foreach ([Validator::class, DelegatedExclusionValidator::class] as $validatorClass) { + $validator = $this->makeValidator( + ['parent' => ['child' => 'invalid'], 'flag' => 'yes'], + [ + 'parent.child' => 'integer', + 'parent' => 'exclude_if:flag,yes', + ], + $validatorClass, + ); + + $this->assertFalse($validator->passes(), $validatorClass); + $this->assertTrue($validator->errors()->has('parent.child'), $validatorClass); + $this->assertSame(['flag' => 'yes'], $validator->getData(), $validatorClass); + } + } + + public function testGlobalEarlyStopDoesNotActivateALaterExclusionHint(): void + { + $validator = $this->makeValidator( + ['first' => 'invalid', 'secret' => 'value'], + ['first' => 'integer', 'secret' => 'exclude'], + )->stopOnFirstFailure(); + + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('first')); + $this->assertSame(['first' => 'invalid', 'secret' => 'value'], $validator->getData()); + } + + public function testAbsentSometimesAttributeStillRunsLaterExclusion(): void + { + foreach ([Validator::class, DelegatedExclusionValidator::class] as $validatorClass) { + $validator = $this->makeValidator( + ['flag' => 'yes'], + [ + 'parent' => 'sometimes|string|exclude_if:flag,yes', + 'parent.child' => 'required', + ], + $validatorClass, + ); + + $this->assertTrue($validator->passes(), $validatorClass); + $this->assertSame([], $validator->errors()->toArray(), $validatorClass); + $this->assertArrayNotHasKey('parent', $validator->getRules(), $validatorClass); + $this->assertArrayNotHasKey('parent.child', $validator->getRules(), $validatorClass); + } + } + + public function testGlobalEarlyStopDoesNotConvertALaterNonScalarExclusionParameter(): void + { + $field = new ValidationExclusionStringable('flag'); + $validator = $this->makeValidator( + ['first' => 'invalid', 'flag' => 'yes', 'target' => 'value'], + [ + 'first' => 'integer', + 'target' => [['exclude_if', $field, 'yes'], 'string'], + ], + )->stopOnFirstFailure(); + + $this->assertFalse($validator->passes()); + $this->assertSame(0, $field->casts); + $this->assertSame(['Integer'], array_keys($validator->failed()['first'])); + $this->assertSame( + ['first' => 'invalid', 'flag' => 'yes', 'target' => 'value'], + $validator->getData(), + ); + } + + public function testReachedNonScalarExclusionParameterUsesOrdinaryConversionOnce(): void + { + $field = new ValidationExclusionStringable('flag'); + $validator = $this->makeValidator( + ['flag' => 'yes', 'target' => 'value'], + ['target' => [['exclude_if', $field, 'yes'], 'string']], + ); + + $this->assertTrue($validator->passes()); + $this->assertSame(1, $field->casts); + $this->assertSame(['flag' => 'yes'], $validator->getData()); + } + + public function testLaterExclusionUsesDataAfterExcludedDescendantRemoval(): void + { + $validator = $this->makeValidator( + ['parent' => ['child' => 'value'], 'later' => 'invalid'], + [ + 'parent' => 'exclude', + 'parent.child' => 'string', + 'later' => 'exclude_if:parent.child,value|integer', + ], + ); + + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('later')); + $this->assertSame(['later' => 'invalid'], $validator->getData()); + } + + public function testLaterExclusionCanStillReadExcludedParentUntilFinalCleanup(): void + { + $validator = $this->makeValidator( + ['parent' => ['child' => 'value'], 'later' => 'invalid'], + [ + 'parent' => 'exclude', + 'later' => 'exclude_if:parent.child,value|integer', + ], + ); + + $this->assertTrue($validator->passes()); + $this->assertSame([], $validator->getData()); + } + + public function testLaterDependentRuleCanReadExcludedParentUntilFinalCleanup(): void + { + $validator = $this->makeValidator( + ['parent' => ['child' => 'value'], 'later' => 'invalid'], + [ + 'parent' => 'exclude', + 'later' => 'required_with:parent|integer', + ], + ); + + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('later')); + $this->assertSame(['later' => 'invalid'], $validator->getData()); + } + + public function testLaterPositionExclusionUsesInternalEscapedDotKey(): void { - return new Validator( + $laterRuleCalls = 0; + $validator = $this->makeValidator( + ['literal.dot' => 'invalid'], + [ + 'literal\.dot' => [ + 'integer', + 'exclude', + function () use (&$laterRuleCalls): bool { + ++$laterRuleCalls; + + return false; + }, + ], + ], + ); + + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('literal.dot')); + $this->assertSame(0, $laterRuleCalls); + $this->assertSame([], $validator->getData()); + } + + /** + * @param class-string $validatorClass + */ + private function makeValidator( + array $data, + array $rules, + string $validatorClass = Validator::class, + ): Validator { + return new $validatorClass( new Translator(new ArrayLoader, 'en'), $data, $rules, ); } } + +class DelegatedExclusionValidator extends Validator +{ +} + +class ValidationExclusionStringable implements Stringable +{ + public int $casts = 0; + + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + ++$this->casts; + + return $this->value; + } +} diff --git a/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php b/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php index e88cf64ebc..9b63fc1145 100644 --- a/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php +++ b/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php @@ -4,160 +4,352 @@ namespace Hypervel\Tests\Validation; +use DateTimeImmutable; use Hypervel\Tests\TestCase; +use Hypervel\Validation\DatabasePresenceVerifierInterface; use Hypervel\Validation\PrecomputedPresenceVerifier; -use Hypervel\Validation\PresenceVerifierInterface; use Mockery as m; +use RuntimeException; +use stdClass; use Stringable; class ValidationPrecomputedPresenceVerifierTest extends TestCase { - public function testGetCountReturnsOneForExistingValue(): void + public function testLookupKeyModelsTheCompleteEffectiveQueryShape(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', ['foo@bar.com', 'baz@bar.com']); - - $this->assertSame(1, $verifier->getCount('users', 'email', 'foo@bar.com')); + $base = self::lookupKey(null, 'users', 'email', extra: ['status' => 'active', 'deleted_at' => 'NULL']); + + $this->assertSame($base, self::lookupKey(null, 'users', 'email', 'NULL', 'uuid', ['status' => 'active', 'deleted_at' => 'NULL'])); + $this->assertNotSame($base, self::lookupKey('tenant', 'users', 'email', extra: ['status' => 'active', 'deleted_at' => 'NULL'])); + $this->assertNotSame($base, self::lookupKey(null, 'admins', 'email', extra: ['status' => 'active', 'deleted_at' => 'NULL'])); + $this->assertNotSame($base, self::lookupKey(null, 'users', 'username', extra: ['status' => 'active', 'deleted_at' => 'NULL'])); + $this->assertNotSame($base, self::lookupKey(null, 'users', 'email', extra: ['deleted_at' => 'NULL', 'status' => 'active'])); + $this->assertNotSame($base, self::lookupKey(null, 'users', 'email', extra: ['status' => 'inactive', 'deleted_at' => 'NULL'])); } - public function testGetCountReturnsZeroForMissingValue(): void + public function testLookupKeyUsesEffectiveExclusionAndNormalizedConditions(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', ['foo@bar.com']); + $defaultId = self::lookupKey(null, 'users', 'email', '7', null, ['active' => true, 'archived' => false]); - $this->assertSame(0, $verifier->getCount('users', 'email', 'missing@bar.com')); + $this->assertSame($defaultId, self::lookupKey(null, 'users', 'email', '7', 'id', ['active' => '1', 'archived' => ''])); + $this->assertNotSame($defaultId, self::lookupKey(null, 'users', 'email', '8', 'id', ['active' => '1', 'archived' => ''])); + $this->assertNotSame($defaultId, self::lookupKey(null, 'users', 'email', '7', 'uuid', ['active' => '1', 'archived' => ''])); } - public function testGetCountCastsValueToStringForComparison(): void + public function testLookupKeyRejectsConditionsThatCannotBeReplayed(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'id', [1, 2, 3]); + $casts = 0; + $stringable = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + public function __toString(): string + { + ++$this->casts; - $this->assertSame(1, $verifier->getCount('users', 'id', '2')); - $this->assertSame(0, $verifier->getCount('users', 'id', '99')); + return 'active'; + } + }; + + $this->assertNull(PrecomputedPresenceVerifier::lookupKey(null, 'users', 'email', extra: [static function (): void {}])); + $this->assertNull(PrecomputedPresenceVerifier::lookupKey(null, 'users', 'email', extra: ['status' => new stdClass])); + $this->assertNull(PrecomputedPresenceVerifier::lookupKey(null, 'users', 'email', extra: ['status' => $stringable])); + $this->assertSame(0, $casts); } - public function testGetMultiCountCountsMatches(): void + public function testScalarFactsUseTheirDatabaseProvenState(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', ['a@b.com', 'c@d.com', 'e@f.com']); - - $this->assertSame(2, $verifier->getMultiCount('users', 'email', ['a@b.com', 'c@d.com', 'missing@x.com'])); + $verifier = $this->makeVerifierWithUnusedFallback(); + $lookupKey = self::lookupKey(null, 'users', 'email'); + $verifier->addLookup( + $lookupKey, + exactHits: self::bindingMap(['exact@example.com']), + knownPresent: self::bindingMap(['case@example.com']), + provenAbsent: self::bindingMap(['missing@example.com']), + stageOneSingleChunk: true, + ); + + $this->assertSame(1, $verifier->getCount('users', 'email', 'exact@example.com')); + $this->assertSame(1, $verifier->getCount('users', 'email', 'case@example.com')); + $this->assertSame(0, $verifier->getCount('users', 'email', 'missing@example.com')); } - public function testFallbackUsedWhenNoLookupRegistered(): void + public function testFactsRequireTheSubmittedBindingIdentity(): void { - $fallback = m::mock(PresenceVerifierInterface::class); + $casts = 0; + $stringable = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + public function __toString(): string + { + ++$this->casts; + + return '3'; + } + }; + $fallback = m::mock(DatabasePresenceVerifierInterface::class); $fallback->shouldReceive('getCount') - ->with('users', 'email', 'foo@bar.com', null, null, []) ->once() + ->with('users', 'id', m::on(static fn (mixed $value): bool => $value === $stringable), null, null, []) ->andReturn(1); - + $fallback->shouldReceive('getCount') + ->once() + ->with('users', 'id', 1, null, null, []) + ->andReturn(0); + $fallback->shouldReceive('getCount') + ->once() + ->with('users', 'id', '2', null, null, []) + ->andReturn(0); $verifier = new PrecomputedPresenceVerifier($fallback); - - $this->assertSame(1, $verifier->getCount('users', 'email', 'foo@bar.com')); + $verifier->addLookup( + self::lookupKey(null, 'users', 'id'), + exactHits: self::bindingMap(['1', 2, '3']), + knownPresent: [], + provenAbsent: [], + stageOneSingleChunk: true, + ); + + $this->assertSame(1, $verifier->getCount('users', 'id', '1')); + $this->assertSame(1, $verifier->getCount('users', 'id', 2)); + $this->assertSame(1, $verifier->getCount('users', 'id', $stringable)); + $this->assertSame(0, $casts); + $this->assertSame(0, $verifier->getCount('users', 'id', 1)); + $this->assertSame(0, $verifier->getCount('users', 'id', '2')); } - public function testFallbackUsedForGetMultiCountWhenNoLookup(): void + public function testUnknownScalarFallbackCountsAreMemoizedPerQueryShapeAndValue(): void { - $fallback = m::mock(PresenceVerifierInterface::class); - $fallback->shouldReceive('getMultiCount') - ->with('users', 'email', ['a@b.com'], []) + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $fallback->shouldReceive('getCount') + ->with('users', 'email', 'shared', null, null, ['status' => 'active']) + ->once() + ->andReturn(1); + $fallback->shouldReceive('getCount') + ->with('users', 'email', 'shared', null, null, ['status' => 'inactive']) + ->once() + ->andReturn(0); + $fallback->shouldReceive('getCount') + ->with('admins', 'email', 'shared', null, null, []) + ->once() + ->andReturn(2); + $fallback->shouldReceive('getCount') + ->with('users', 'email', m::on(static fn (mixed $value): bool => $value === '1'), null, null, []) ->once() ->andReturn(1); + $fallback->shouldReceive('getCount') + ->with('users', 'email', m::on(static fn (mixed $value): bool => $value === 1), null, null, []) + ->once() + ->andReturn(0); $verifier = new PrecomputedPresenceVerifier($fallback); - - $this->assertSame(1, $verifier->getMultiCount('users', 'email', ['a@b.com'])); + $verifier->addLookup(self::lookupKey(null, 'users', 'email', extra: ['status' => 'active']), [], [], [], true); + $verifier->addLookup(self::lookupKey(null, 'users', 'email', extra: ['status' => 'inactive']), [], [], [], true); + $verifier->addLookup(self::lookupKey(null, 'admins', 'email'), [], [], [], true); + $verifier->addLookup(self::lookupKey(null, 'users', 'email'), [], [], [], true); + + for ($iteration = 0; $iteration < 2; ++$iteration) { + $this->assertSame(1, $verifier->getCount('users', 'email', 'shared', extra: ['status' => 'active'])); + $this->assertSame(0, $verifier->getCount('users', 'email', 'shared', extra: ['status' => 'inactive'])); + $this->assertSame(2, $verifier->getCount('admins', 'email', 'shared')); + $this->assertSame(1, $verifier->getCount('users', 'email', '1')); + $this->assertSame(0, $verifier->getCount('users', 'email', 1)); + } } - public function testNoFallbackReturnsZero(): void + public function testUnregisteredAndUnsupportedScalarShapesDelegateWithoutMemoization(): void { - $verifier = new PrecomputedPresenceVerifier; + $unsupported = false; + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $fallback->shouldReceive('getCount') + ->with('users', 'email', 'unregistered', null, null, ['status' => 'active']) + ->twice() + ->andReturn(1); + $fallback->shouldReceive('getCount') + ->with('users', 'email', $unsupported, null, null, []) + ->twice() + ->andReturn(0); - $this->assertSame(0, $verifier->getCount('users', 'email', 'foo@bar.com')); - $this->assertSame(0, $verifier->getMultiCount('users', 'email', ['foo@bar.com'])); + $verifier = new PrecomputedPresenceVerifier($fallback); + $verifier->addLookup(self::lookupKey(null, 'users', 'email'), [], [], [], true); + + for ($iteration = 0; $iteration < 2; ++$iteration) { + $this->assertSame(1, $verifier->getCount('users', 'email', 'unregistered', extra: ['status' => 'active'])); + $this->assertSame(0, $verifier->getCount('users', 'email', $unsupported)); + } } - public function testHasLookupsReturnsTrueWhenLookupsRegistered(): void + public function testConnectionIsPartOfTheLookupAndForwardedToTheFallback(): void { - $verifier = new PrecomputedPresenceVerifier; - - $this->assertFalse($verifier->hasLookups()); + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $fallback->shouldReceive('setConnection')->once()->with('tenant'); + $fallback->shouldReceive('getCount')->once()->with('users', 'email', 'unknown', null, null, [])->andReturn(1); - $verifier->addLookup('users', 'email', ['foo@bar.com']); - - $this->assertTrue($verifier->hasLookups()); + $verifier = new PrecomputedPresenceVerifier($fallback); + $verifier->addLookup( + self::lookupKey('tenant', 'users', 'email'), + exactHits: self::bindingMap(['known']), + knownPresent: [], + provenAbsent: [], + stageOneSingleChunk: true, + ); + $verifier->setConnection('tenant'); + + $this->assertSame(1, $verifier->getCount('users', 'email', 'known')); + $this->assertSame(1, $verifier->getCount('users', 'email', 'unknown')); } - public function testNullValuesAreExcludedFromLookup(): void + public function testMultiCountUsesExactAndAbsentFactsFromOneDistinctQuery(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', [null, 'foo@bar.com', null]); + $verifier = $this->makeVerifierWithUnusedFallback(); + $verifier->addLookup( + self::lookupKey(null, 'users', 'email'), + exactHits: self::bindingMap(['first', 'second']), + knownPresent: [], + provenAbsent: self::bindingMap(['missing']), + stageOneSingleChunk: true, + ); + + $this->assertSame(2, $verifier->getMultiCount('users', 'email', ['first', 'first', 'second', 'missing'])); + } - $this->assertSame(1, $verifier->getCount('users', 'email', 'foo@bar.com')); - $this->assertSame(0, $verifier->getCount('users', 'email', '')); + public function testMultiCountUsesKnownPresentOnlyForASoleDistinctInput(): void + { + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $fallback->shouldReceive('getMultiCount')->once()->with('users', 'email', ['case', 'exact'], [])->andReturn(1); + $verifier = new PrecomputedPresenceVerifier($fallback); + $verifier->addLookup( + self::lookupKey(null, 'users', 'email'), + exactHits: self::bindingMap(['exact']), + knownPresent: self::bindingMap(['case']), + provenAbsent: [], + stageOneSingleChunk: true, + ); + + $this->assertSame(1, $verifier->getMultiCount('users', 'email', ['case'])); + $this->assertSame(1, $verifier->getMultiCount('users', 'email', ['case', 'exact'])); } - public function testNonScalarValueReturnsZero(): void + public function testMultiCountDelegatesUnknownUnsupportedAndCrossChunkFactsAsAWhole(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', ['foo@bar.com']); + $casts = 0; + $stringable = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } - $this->assertSame(0, $verifier->getCount('users', 'email', ['array'])); + public function __toString(): string + { + ++$this->casts; + + return 'exact'; + } + }; + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $fallback->shouldReceive('getMultiCount')->once()->with('users', 'email', ['unknown'], [])->andReturn(1); + $fallback->shouldReceive('getMultiCount')->once()->with('users', 'email', [false], [])->andReturn(0); + $fallback->shouldReceive('getMultiCount') + ->once() + ->with('users', 'email', m::on(static fn (array $values): bool => $values === [$stringable]), []) + ->andReturn(1); + $fallback->shouldReceive('getMultiCount')->once()->with('users', 'email', ['exact'], [])->andReturn(1); + $verifier = new PrecomputedPresenceVerifier($fallback); + $verifier->addLookup( + self::lookupKey(null, 'users', 'email'), + exactHits: self::bindingMap(['exact']), + knownPresent: [], + provenAbsent: [], + stageOneSingleChunk: false, + ); + + $this->assertSame(1, $verifier->getMultiCount('users', 'email', ['unknown'])); + $this->assertSame(0, $verifier->getMultiCount('users', 'email', [false])); + $this->assertSame(1, $verifier->getMultiCount('users', 'email', [$stringable])); + $this->assertSame(0, $casts); + $this->assertSame(1, $verifier->getMultiCount('users', 'email', ['exact'])); } - public function testUnsupportedValueUsesFallbackWhenLookupIsRegistered(): void + public function testDateTimeBindingsDelegateToTheOrdinaryVerifier(): void { - $value = ['array']; - $fallback = m::mock(PresenceVerifierInterface::class); + $value = new class('2025-01-01 00:00:00') extends DateTimeImmutable implements Stringable { + public function __toString(): string + { + return $this->format(DATE_ATOM); + } + }; + $fallback = m::mock(DatabasePresenceVerifierInterface::class); $fallback->shouldReceive('getCount') - ->with('users', 'email', $value, null, null, []) ->once() + ->with('users', 'created_at', $value, null, null, []) ->andReturn(1); $verifier = new PrecomputedPresenceVerifier($fallback); - $verifier->addLookup('users', 'email', ['foo@bar.com']); + $verifier->addLookup(self::lookupKey(null, 'users', 'created_at'), [], [], [], true); - $this->assertSame(1, $verifier->getCount('users', 'email', $value)); + $this->assertNull(PrecomputedPresenceVerifier::bindingKey($value)); + $this->assertSame(1, $verifier->getCount('users', 'created_at', $value)); } - public function testUnsupportedMultiValueUsesFallbackAsAWhole(): void + public function testHasLookupsReflectsRegisteredQueryShapes(): void { - $values = ['foo@bar.com', ['array']]; - $fallback = m::mock(PresenceVerifierInterface::class); - $fallback->shouldReceive('getMultiCount') - ->with('users', 'email', $values, []) - ->once() - ->andReturn(2); - $verifier = new PrecomputedPresenceVerifier($fallback); - $verifier->addLookup('users', 'email', ['foo@bar.com']); + $verifier = $this->makeVerifierWithUnusedFallback(); + + $this->assertFalse($verifier->hasLookups()); - $this->assertSame(2, $verifier->getMultiCount('users', 'email', $values)); + $verifier->addLookup(self::lookupKey(null, 'users', 'email'), [], [], [], true); + + $this->assertTrue($verifier->hasLookups()); } - public function testStringableValuesUsePrecomputedLookup(): void + /** + * Build a non-null lookup key for scalar query conditions. + */ + private static function lookupKey( + ?string $connection, + string $collection, + string $column, + int|string|null $excludeId = null, + ?string $idColumn = null, + array $extra = [], + ): string { + return PrecomputedPresenceVerifier::lookupKey( + $connection, + $collection, + $column, + $excludeId, + $idColumn, + $extra, + ) ?? throw new RuntimeException('Expected a scalar lookup shape.'); + } + + /** + * Build a fact map from submitted binding identities. + * + * @return array + */ + private static function bindingMap(array $values): array { - $value = new class implements Stringable { - public function __toString(): string - { - return 'foo@bar.com'; - } - }; - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', [$value]); + $bindings = []; - $this->assertSame(1, $verifier->getCount('users', 'email', $value)); + foreach ($values as $value) { + $bindingKey = PrecomputedPresenceVerifier::bindingKey($value) + ?? throw new RuntimeException('Expected a supported presence value.'); + $bindings[$bindingKey] = true; + } + + return $bindings; } - public function testSeparateTableColumnScoping(): void + /** + * Build a verifier whose registered facts must answer every probe. + */ + private function makeVerifierWithUnusedFallback(): PrecomputedPresenceVerifier { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', ['user@a.com']); - $verifier->addLookup('admins', 'email', ['admin@a.com']); - - $this->assertSame(1, $verifier->getCount('users', 'email', 'user@a.com')); - $this->assertSame(0, $verifier->getCount('users', 'email', 'admin@a.com')); - $this->assertSame(1, $verifier->getCount('admins', 'email', 'admin@a.com')); - $this->assertSame(0, $verifier->getCount('admins', 'email', 'user@a.com')); + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $fallback->shouldNotReceive('getCount'); + $fallback->shouldNotReceive('getMultiCount'); + + return new PrecomputedPresenceVerifier($fallback); } } diff --git a/tests/Validation/ValidationRuleCompilerTest.php b/tests/Validation/ValidationRuleCompilerTest.php index 09c184feb4..ca80b6f615 100644 --- a/tests/Validation/ValidationRuleCompilerTest.php +++ b/tests/Validation/ValidationRuleCompilerTest.php @@ -7,10 +7,10 @@ use Hypervel\Contracts\Validation\ImplicitRule; use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Tests\TestCase; +use Hypervel\Validation\AttributePlan; use Hypervel\Validation\ClosureValidationRule; use Hypervel\Validation\DelegatedCheck; use Hypervel\Validation\Enums\CheckType; -use Hypervel\Validation\Enums\SizeMode; use Hypervel\Validation\InlineCheck; use Hypervel\Validation\RuleCompiler; use Hypervel\Validation\Rules\Exists; @@ -19,12 +19,12 @@ class ValidationRuleCompilerTest extends TestCase { - public function testRequiredSetsFlagAndProducesDelegatedCheck() + private const array NUMERIC_RULES = ['Numeric', 'Integer', 'Decimal']; + + public function testRequiredProducesDelegatedCheck(): void { - $plan = RuleCompiler::compile(['required']); + $plan = $this->compile(['required']); - $this->assertTrue($plan->required); - $this->assertTrue($plan->hasImplicitRule); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); $this->assertSame('Required', $plan->checks[0]->ruleName); @@ -32,7 +32,7 @@ public function testRequiredSetsFlagAndProducesDelegatedCheck() public function testNullableSetsFlag() { - $plan = RuleCompiler::compile(['nullable']); + $plan = $this->compile(['nullable']); $this->assertTrue($plan->nullable); $this->assertCount(0, $plan->checks); @@ -40,7 +40,7 @@ public function testNullableSetsFlag() public function testBailSetsFlag() { - $plan = RuleCompiler::compile(['bail']); + $plan = $this->compile(['bail']); $this->assertTrue($plan->bail); $this->assertCount(0, $plan->checks); @@ -48,7 +48,7 @@ public function testBailSetsFlag() public function testSometimesSetsFlag() { - $plan = RuleCompiler::compile(['sometimes']); + $plan = $this->compile(['sometimes']); $this->assertTrue($plan->sometimes); $this->assertCount(0, $plan->checks); @@ -56,14 +56,14 @@ public function testSometimesSetsFlag() public function testEmptyRuleStringProducesNoCheck() { - $plan = RuleCompiler::compile(['']); + $plan = $this->compile(['']); $this->assertCount(0, $plan->checks); } public function testStringInlinesCorrectly() { - $plan = RuleCompiler::compile(['string']); + $plan = $this->compile(['string']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -72,7 +72,7 @@ public function testStringInlinesCorrectly() public function testNumericBareInlines() { - $plan = RuleCompiler::compile(['numeric']); + $plan = $this->compile(['numeric']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -81,7 +81,7 @@ public function testNumericBareInlines() public function testNumericStrictDelegates() { - $plan = RuleCompiler::compile(['numeric:strict']); + $plan = $this->compile(['numeric:strict']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -90,7 +90,7 @@ public function testNumericStrictDelegates() public function testBooleanBareInlines() { - $plan = RuleCompiler::compile(['boolean']); + $plan = $this->compile(['boolean']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -99,7 +99,7 @@ public function testBooleanBareInlines() public function testBooleanStrictDelegates() { - $plan = RuleCompiler::compile(['boolean:strict']); + $plan = $this->compile(['boolean:strict']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -107,7 +107,7 @@ public function testBooleanStrictDelegates() public function testIntegerBareInlines() { - $plan = RuleCompiler::compile(['integer']); + $plan = $this->compile(['integer']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -116,7 +116,7 @@ public function testIntegerBareInlines() public function testIntegerStrictInlines() { - $plan = RuleCompiler::compile(['integer:strict']); + $plan = $this->compile(['integer:strict']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -125,7 +125,7 @@ public function testIntegerStrictInlines() public function testUuidBareInlines() { - $plan = RuleCompiler::compile(['uuid']); + $plan = $this->compile(['uuid']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -134,7 +134,7 @@ public function testUuidBareInlines() public function testUuidWithVersionDelegates() { - $plan = RuleCompiler::compile(['uuid:4']); + $plan = $this->compile(['uuid:4']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -142,7 +142,7 @@ public function testUuidWithVersionDelegates() public function testEmailBareInlines() { - $plan = RuleCompiler::compile(['email']); + $plan = $this->compile(['email']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -151,7 +151,7 @@ public function testEmailBareInlines() public function testEmailWithParamsDelegates() { - $plan = RuleCompiler::compile(['email:rfc,dns']); + $plan = $this->compile(['email:rfc,dns']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -159,7 +159,7 @@ public function testEmailWithParamsDelegates() public function testUrlBareInlines() { - $plan = RuleCompiler::compile(['url']); + $plan = $this->compile(['url']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -167,7 +167,7 @@ public function testUrlBareInlines() public function testUrlWithParamsDelegates() { - $plan = RuleCompiler::compile(['url:http,https']); + $plan = $this->compile(['url:http,https']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -175,7 +175,7 @@ public function testUrlWithParamsDelegates() public function testArrayBareInlines() { - $plan = RuleCompiler::compile(['array']); + $plan = $this->compile(['array']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -184,56 +184,55 @@ public function testArrayBareInlines() public function testArrayWithKeysDelegates() { - $plan = RuleCompiler::compile(['array:name,email']); + $plan = $this->compile(['array:name,email']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); } - public function testSizeRulesWithResolvedMode() + public function testSizeRulesInlineWithoutATypeSibling(): void { - $plan = RuleCompiler::compile(['string', 'max:255']); + $plan = $this->compile(['max:255']); - $this->assertCount(2, $plan->checks); - $this->assertInstanceOf(InlineCheck::class, $plan->checks[1]); - $this->assertSame(CheckType::SizeMax, $plan->checks[1]->type); - $this->assertSame('255', $plan->checks[1]->param['n']); - $this->assertSame(SizeMode::String, $plan->checks[1]->param['mode']); + $this->assertCount(1, $plan->checks); + $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); + $this->assertSame(CheckType::SizeMax, $plan->checks[0]->type); + $this->assertFalse($plan->checks[0]->param['numeric']); + $this->assertSame(['raw' => '255', 'integer' => 255], $plan->checks[0]->param['threshold']); } - public function testSizeRulesWithoutTypeFlagDelegate() + public function testNumericSiblingActivatesNumericSizeSemantics(): void { - $plan = RuleCompiler::compile(['max:255']); + $plan = $this->compile(['numeric', 'max:255']); - $this->assertCount(1, $plan->checks); - $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); - $this->assertSame('Max', $plan->checks[0]->ruleName); + $this->assertInstanceOf(InlineCheck::class, $plan->checks[1]); + $this->assertTrue($plan->checks[1]->param['numeric']); } - public function testSizeRulesWithConflictingTypeFlagsDelegate() + public function testNumericSemanticsRemainActiveWithConflictingTypeSiblings(): void { - $plan = RuleCompiler::compile(['numeric', 'string', 'max:10']); + $plan = $this->compile(['numeric', 'string', 'max:10']); $this->assertCount(3, $plan->checks); - $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[2]); - $this->assertSame('Max', $plan->checks[2]->ruleName); + $this->assertInstanceOf(InlineCheck::class, $plan->checks[2]); + $this->assertTrue($plan->checks[2]->param['numeric']); } - public function testBetweenWithResolvedMode() + public function testBetweenStoresNumericSemanticsAndClassifiedThresholds(): void { - $plan = RuleCompiler::compile(['numeric', 'between:1,100']); + $plan = $this->compile(['decimal:2', 'between:1.5,100']); $this->assertCount(2, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[1]); $this->assertSame(CheckType::SizeBetween, $plan->checks[1]->type); - $this->assertSame('1', $plan->checks[1]->param['min']); - $this->assertSame('100', $plan->checks[1]->param['max']); - $this->assertSame(SizeMode::Numeric, $plan->checks[1]->param['mode']); + $this->assertTrue($plan->checks[1]->param['numeric']); + $this->assertSame(['raw' => '1.5', 'integer' => null], $plan->checks[1]->param['minimum']); + $this->assertSame(['raw' => '100', 'integer' => 100], $plan->checks[1]->param['maximum']); } public function testInWithoutSiblingArrayInlines() { - $plan = RuleCompiler::compile(['in:a,b,c']); + $plan = $this->compile(['in:a,b,c']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -242,7 +241,7 @@ public function testInWithoutSiblingArrayInlines() public function testInWithSiblingArrayDelegates() { - $plan = RuleCompiler::compile(['array', 'in:a,b,c']); + $plan = $this->compile(['array', 'in:a,b,c']); $this->assertCount(2, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[1]); @@ -251,7 +250,7 @@ public function testInWithSiblingArrayDelegates() public function testNotInWithSiblingArrayDelegates() { - $plan = RuleCompiler::compile(['array', 'not_in:a,b,c']); + $plan = $this->compile(['array', 'not_in:a,b,c']); $this->assertCount(2, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[1]); @@ -261,7 +260,7 @@ public function testArrayFormSiblingArrayTriggersDelegation() { // Array-form ['array'] must be detected as a sibling array rule, // causing 'in' to delegate (uses array_diff branch in validateIn). - $plan = RuleCompiler::compile([['array'], 'in:a,b,c']); + $plan = $this->compile([['array'], 'in:a,b,c']); $this->assertCount(2, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[1]); @@ -270,7 +269,7 @@ public function testArrayFormSiblingArrayTriggersDelegation() public function testParameterizedArrayTriggersDelegation() { - $plan = RuleCompiler::compile(['array:foo,bar', 'in:a,b,c']); + $plan = $this->compile(['array:foo,bar', 'in:a,b,c']); $this->assertCount(2, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -279,7 +278,7 @@ public function testParameterizedArrayTriggersDelegation() public function testDateWithLiteralTargetInlines() { - $plan = RuleCompiler::compile(['after:2025-01-01']); + $plan = $this->compile(['after:2025-01-01']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -290,7 +289,7 @@ public function testDateWithLiteralTargetInlines() public function testDateWithFieldRefDelegates() { - $plan = RuleCompiler::compile(['after:start_date']); + $plan = $this->compile(['after:start_date']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -298,7 +297,7 @@ public function testDateWithFieldRefDelegates() public function testDateWithHyphenatedFieldRefDelegates(): void { - $plan = RuleCompiler::compile(['after:start-date']); + $plan = $this->compile(['after:start-date']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -306,7 +305,7 @@ public function testDateWithHyphenatedFieldRefDelegates(): void public function testDateWithDigitLeadingFieldRefDelegates(): void { - $plan = RuleCompiler::compile(['after:2fa-expiry']); + $plan = $this->compile(['after:2fa-expiry']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -314,7 +313,7 @@ public function testDateWithDigitLeadingFieldRefDelegates(): void public function testDateWithAmbiguousTimestampDelegates(): void { - $plan = RuleCompiler::compile(['after:20250102T120000Z']); + $plan = $this->compile(['after:20250102T120000Z']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -322,7 +321,7 @@ public function testDateWithAmbiguousTimestampDelegates(): void public function testDateWithEscapedDotFieldRefDelegates(): void { - $plan = RuleCompiler::compile(['after:a\.b']); + $plan = $this->compile(['after:a\.b']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -330,39 +329,86 @@ public function testDateWithEscapedDotFieldRefDelegates(): void public function testDateWithSiblingFormatBaked() { - $plan = RuleCompiler::compile(['date_format:Y-m-d', 'after:2025-01-01']); + $plan = $this->compile(['date_format:Y-m-d', 'after:2025-01-01']); $this->assertCount(2, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[1]); $this->assertSame('Y-m-d', $plan->checks[1]->param['format']); } - public function testCastableArrayFormDateFormatsAreNormalizedForSiblingChecks(): void + public function testScalarArrayFormDateFormatStillProvidesSiblingContext(): void + { + $plan = $this->compile([['date_format', 123], 'after:124']); + + $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); + $this->assertInstanceOf(InlineCheck::class, $plan->checks[1]); + $this->assertSame('123', $plan->checks[1]->param['format']); + } + + public function testNonScalarArrayFormDateFormatDelegatesTheWholeAttributeWithoutCasting(): void { $stringable = new class implements Stringable { + public int $casts = 0; + public function __toString(): string { + ++$this->casts; + return 'Y-m-d'; } }; - $integerPlan = RuleCompiler::compile([['date_format', 123], 'after:124']); - $stringablePlan = RuleCompiler::compile([['date_format', $stringable], 'after:2025-01-01']); + $plan = $this->compile([['date_format', $stringable], 'after:2025-01-01']); + + $this->assertSame(0, $stringable->casts); + $this->assertCount(2, $plan->checks); + $this->assertContainsOnlyInstancesOf(DelegatedCheck::class, $plan->checks); + $this->assertFalse($plan->checks[0]->parametersAreScalar); + $this->assertTrue($plan->checks[1]->parametersAreScalar); + } + + public function testNestedArrayParameterDelegatesTheWholeAttribute(): void + { + $plan = $this->compile([['date_format', []], 'string']); + + $this->assertCount(2, $plan->checks); + $this->assertContainsOnlyInstancesOf(DelegatedCheck::class, $plan->checks); + } + + public function testNullParameterDelegatesTheWholeAttribute(): void + { + $plan = $this->compile([['in', null], 'max:5']); + + $this->assertCount(2, $plan->checks); + $this->assertContainsOnlyInstancesOf(DelegatedCheck::class, $plan->checks); + } + + public function testResourceParameterDelegatesTheWholeAttribute(): void + { + $resource = fopen('php://memory', 'r'); + + try { + $plan = $this->compile([['in', $resource], 'max:5']); - $this->assertSame('123', $integerPlan->checks[1]->param['format']); - $this->assertSame('Y-m-d', $stringablePlan->checks[1]->param['format']); + $this->assertCount(2, $plan->checks); + $this->assertContainsOnlyInstancesOf(DelegatedCheck::class, $plan->checks); + } finally { + fclose($resource); + } } - public function testMalformedArrayFormDateFormatDoesNotPoisonSiblingCompilation(): void + public function testScalarArrayTupleKeepsTheOptimizedPath(): void { - $plan = RuleCompiler::compile([['date_format', []], 'after:2025-01-01']); + $plan = $this->compile([['in', 'a', 2], 'max:5']); - $this->assertNull($plan->checks[1]->param['format']); + $this->assertCount(2, $plan->checks); + $this->assertContainsOnlyInstancesOf(InlineCheck::class, $plan->checks); + $this->assertSame(['a', '2'], $plan->checks[0]->param); } public function testDateFormatStoresAllFormats() { - $plan = RuleCompiler::compile(['date_format:Y-m-d H:i:s,H:i:s']); + $plan = $this->compile(['date_format:Y-m-d H:i:s,H:i:s']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -374,7 +420,7 @@ public function testMixedInlineAndDelegated() { $existsRule = new Exists('users', 'email'); - $plan = RuleCompiler::compile(['required', 'string', 'max:255', $existsRule]); + $plan = $this->compile(['required', 'string', 'max:255', $existsRule]); $inlineCount = 0; $delegatedCount = 0; @@ -390,26 +436,25 @@ public function testMixedInlineAndDelegated() $this->assertSame(2, $delegatedCount); } - public function testExistsRuleObjectStoresRuleAndOriginal() + public function testExistsRuleObjectStoresOriginalRule(): void { $existsRule = new Exists('users', 'email'); - $plan = RuleCompiler::compile([$existsRule]); + $plan = $this->compile([$existsRule]); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); - $this->assertSame($existsRule, $plan->checks[0]->ruleObject); $this->assertSame($existsRule, $plan->checks[0]->originalRule); $this->assertSame('Exists', $plan->checks[0]->ruleName); } - public function testUniqueRuleObjectStoresRuleAndOriginal() + public function testUniqueRuleObjectStoresOriginalRule(): void { $uniqueRule = new Unique('users', 'email'); - $plan = RuleCompiler::compile([$uniqueRule]); + $plan = $this->compile([$uniqueRule]); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); - $this->assertSame($uniqueRule, $plan->checks[0]->ruleObject); + $this->assertSame($uniqueRule, $plan->checks[0]->originalRule); $this->assertSame('Unique', $plan->checks[0]->ruleName); } @@ -419,14 +464,14 @@ public function testClosureRuleProducesDelegatedCheck() return true; }); - $plan = RuleCompiler::compile([$closure]); + $plan = $this->compile([$closure]); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); - $this->assertSame($closure, $plan->checks[0]->ruleObject); + $this->assertSame($closure, $plan->checks[0]->originalRule); } - public function testImplicitInvokableRuleSetsHasImplicitRule() + public function testImplicitInvokableRuleProducesDelegatedCheck(): void { $implicitRule = new class implements RuleContract, ImplicitRule { public function passes(string $attribute, mixed $value): bool @@ -440,23 +485,24 @@ public function message(): array|string } }; - $plan = RuleCompiler::compile([$implicitRule]); + $plan = $this->compile([$implicitRule]); - $this->assertTrue($plan->hasImplicitRule); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); } - public function testImplicitStringRulesSetsHasImplicitRule() + public function testImplicitStringRuleProducesDelegatedCheck(): void { - $plan = RuleCompiler::compile(['accepted']); + $plan = $this->compile(['accepted']); - $this->assertTrue($plan->hasImplicitRule); + $this->assertCount(1, $plan->checks); + $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); + $this->assertSame('Accepted', $plan->checks[0]->ruleName); } public function testAlphaAsciiVariant() { - $plan = RuleCompiler::compile(['alpha:ascii']); + $plan = $this->compile(['alpha:ascii']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -465,7 +511,7 @@ public function testAlphaAsciiVariant() public function testArrayFormRuleParsedCorrectly() { - $plan = RuleCompiler::compile([['required_array_keys', 'name']]); + $plan = $this->compile([['required_array_keys', 'name']]); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); @@ -475,26 +521,36 @@ public function testArrayFormRuleParsedCorrectly() public function testEmptyArrayRuleSkipped() { - $plan = RuleCompiler::compile([[]]); + $plan = $this->compile([[]]); $this->assertCount(0, $plan->checks); } - public function testCompileAllDelegatedProducesNoDelegatedChecks() + public function testCompileAllDelegatedProducesOnlyDelegatedChecks(): void { - $plan = RuleCompiler::compileAllDelegated(['required', 'string', 'max:255']); + $plan = RuleCompiler::compileAllDelegated([ + 'nullable', + 'bail', + 'sometimes', + 'required', + 'string', + 'max:255', + ]); foreach ($plan->checks as $check) { $this->assertInstanceOf(DelegatedCheck::class, $check); + $this->assertTrue($check->parametersAreScalar); } - $this->assertTrue($plan->required); - $this->assertTrue($plan->hasImplicitRule); + $this->assertTrue($plan->nullable); + $this->assertTrue($plan->bail); + $this->assertTrue($plan->sometimes); + $this->assertCount(6, $plan->checks); } public function testMultipleOfLiteralInlines() { - $plan = RuleCompiler::compile(['multiple_of:5']); + $plan = $this->compile(['multiple_of:5']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -503,20 +559,72 @@ public function testMultipleOfLiteralInlines() public function testMultipleOfFieldRefDelegates() { - $plan = RuleCompiler::compile(['multiple_of:other_field']); + $plan = $this->compile(['multiple_of:other_field']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); } - public function testSizeModeResolvesFromParameterizedArray() + public function testParameterizedArrayStillAllowsRuntimeDispatchedSizeInlining(): void { - $plan = RuleCompiler::compile(['array:name,email', 'max:5']); + $plan = $this->compile(['array:name,email', 'max:5']); - $this->assertSame(SizeMode::Array, $plan->sizeMode); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0]); - // max:5 delegates because array was parameterized but still sets Array mode - // However, in:* would delegate because hasSiblingArrayRule is true + $this->assertInstanceOf(InlineCheck::class, $plan->checks[1]); + $this->assertFalse($plan->checks[1]->param['numeric']); + } + + public function testSizeThresholdsAreNormalizedAndClassifiedOnceAtCompilation(): void + { + $plan = $this->compile(['max: 5 ', 'min:1e3', 'size:9223372036854775808']); + + $this->assertSame(['raw' => '5', 'integer' => 5], $plan->checks[0]->param['threshold']); + $this->assertSame(['raw' => '1e3', 'integer' => null], $plan->checks[1]->param['threshold']); + $this->assertSame( + ['raw' => '9223372036854775808', 'integer' => null], + $plan->checks[2]->param['threshold'], + ); + } + + public function testCompilerParsesEachRuleOnceForContextAndEmission(): void + { + $rule = new class implements Stringable { + public int $casts = 0; + + public function __toString(): string + { + ++$this->casts; + + return 'max:5'; + } + }; + + $plan = $this->compile([$rule]); + + $this->assertSame(1, $rule->casts); + $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); + } + + public function testDelegatedFallbackReusesParsedRules(): void + { + $existsRule = new class('users', 'email') extends Exists { + public int $casts = 0; + + public function __toString(): string + { + ++$this->casts; + + return parent::__toString(); + } + }; + $existsRule->using(static fn (mixed $query): mixed => $query); + + $plan = $this->compile([$existsRule, ['date_format', []]]); + + $this->assertSame(1, $existsRule->casts); + $this->assertCount(2, $plan->checks); + $this->assertContainsOnlyInstancesOf(DelegatedCheck::class, $plan->checks); + $this->assertSame($existsRule, $plan->checks[0]->originalRule); } public function testFormatCheckTypesInline() @@ -528,7 +636,7 @@ public function testFormatCheckTypesInline() ]; foreach ($types as $i => $type) { - $plan = RuleCompiler::compile([$type]); + $plan = $this->compile([$type]); $this->assertCount(1, $plan->checks, "Failed for rule: {$type}"); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0], "Failed for rule: {$type}"); $this->assertSame($expected[$i], $plan->checks[0]->type, "Failed for rule: {$type}"); @@ -537,7 +645,7 @@ public function testFormatCheckTypesInline() public function testDigitsInlines() { - $plan = RuleCompiler::compile(['digits:5']); + $plan = $this->compile(['digits:5']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -553,7 +661,7 @@ public function testMalformedDigitParametersDelegateInsteadOfBeingTruncated(): v 'min_digits:2.9', 'max_digits:abc', ] as $rule) { - $plan = RuleCompiler::compile([$rule]); + $plan = $this->compile([$rule]); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0], $rule); } @@ -561,7 +669,7 @@ public function testMalformedDigitParametersDelegateInsteadOfBeingTruncated(): v public function testRegexInlines() { - $plan = RuleCompiler::compile(['regex:/^[a-z]+$/']); + $plan = $this->compile(['regex:/^[a-z]+$/']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -571,7 +679,7 @@ public function testRegexInlines() public function testStartsWithInlines() { - $plan = RuleCompiler::compile(['starts_with:foo,bar']); + $plan = $this->compile(['starts_with:foo,bar']); $this->assertCount(1, $plan->checks); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0]); @@ -584,7 +692,7 @@ public function testCrossFieldRulesDelegated() $crossFieldRules = ['same:other', 'different:other', 'confirmed', 'gt:other', 'gte:other', 'lt:other', 'lte:other']; foreach ($crossFieldRules as $rule) { - $plan = RuleCompiler::compile([$rule]); + $plan = $this->compile([$rule]); $this->assertInstanceOf(DelegatedCheck::class, $plan->checks[0], "Expected DelegatedCheck for: {$rule}"); } } @@ -592,8 +700,16 @@ public function testCrossFieldRulesDelegated() public function testDateLiteralsRecognized() { foreach (['today', 'yesterday', 'tomorrow', 'now'] as $literal) { - $plan = RuleCompiler::compile(["after:{$literal}"]); + $plan = $this->compile(["after:{$literal}"]); $this->assertInstanceOf(InlineCheck::class, $plan->checks[0], "Expected InlineCheck for date literal: {$literal}"); } } + + /** + * Compile rules with the base validator's canonical numeric-rule set. + */ + private function compile(array $rules): AttributePlan + { + return RuleCompiler::compile($rules, self::NUMERIC_RULES); + } } diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index 20f1ed4d32..83dfe20792 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -533,6 +533,56 @@ public function testExplodeHandlesStringRuleWithAdditionalRulesInsideArray(): vo ], $results->rules); } + public function testExplodeCanonicalizesStringableFluentRules(): void + { + $results = (new ValidationRuleParser(['value' => 'allowed']))->explode([ + 'value' => [ + Rule::in(['allowed', 'other']), + Rule::notIn(['blocked']), + Rule::dimensions()->maxWidth(100), + Rule::exists('users', 'email'), + Rule::unique('users', 'email')->ignore(0), + ], + ]); + + $this->assertSame([ + 'in:"allowed","other"', + 'not_in:"blocked"', + 'dimensions:max_width=100', + 'exists:users,email', + 'unique:users,email,"0",id', + ], $results->rules['value']); + } + + public function testExplodePreservesCallbackBearingPresenceRules(): void + { + $exists = Rule::exists('users', 'email')->where(static fn ($query) => $query); + $unique = Rule::unique('users', 'email')->where(static fn ($query) => $query); + + $results = (new ValidationRuleParser(['email' => 'user@example.com']))->explode([ + 'email' => [$exists, $unique], + ]); + + $this->assertSame([$exists, $unique], $results->rules['email']); + } + + public function testExplodeEvaluatesConditionalFluentRuleOnce(): void + { + $calls = 0; + $rule = Rule::requiredIf(function () use (&$calls): bool { + ++$calls; + + return true; + }); + + $results = (new ValidationRuleParser(['value' => 'present']))->explode([ + 'value' => [$rule], + ]); + + $this->assertSame(['required'], $results->rules['value']); + $this->assertSame(1, $calls); + } + public function testExplodeExpandsWildcardStringRules(): void { $parser = new ValidationRuleParser([ diff --git a/tests/Validation/ValidationRulePlanCacheTest.php b/tests/Validation/ValidationRulePlanCacheTest.php index b398464a96..01ce232ea5 100644 --- a/tests/Validation/ValidationRulePlanCacheTest.php +++ b/tests/Validation/ValidationRulePlanCacheTest.php @@ -5,14 +5,21 @@ namespace Hypervel\Tests\Validation; use Hypervel\Tests\TestCase; +use Hypervel\Validation\AttributePlan; +use Hypervel\Validation\Enums\CheckType; +use Hypervel\Validation\InlineCheck; +use Hypervel\Validation\Rule; use Hypervel\Validation\RuleCompiler; use Hypervel\Validation\RulePlanCache; +use Hypervel\Validation\ValidationRuleParser; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use stdClass; class ValidationRulePlanCacheTest extends TestCase { + private const array NUMERIC_RULES = ['Numeric', 'Integer', 'Decimal']; + protected function setUp(): void { parent::setUp(); @@ -23,7 +30,7 @@ protected function setUp(): void public function testCacheHitReturnsSamePlanInstance(): void { $rules = ['required', 'string', 'max:255']; - $plan = RuleCompiler::compile($rules); + $plan = $this->compile($rules); RulePlanCache::put($rules, $plan); @@ -42,10 +49,29 @@ public function testNonStringElementsReturnNull(): void $this->assertNull(RulePlanCache::get(['required', new stdClass])); } + public function testCanonicalFluentRulesShareCachedPlans(): void + { + $firstRules = (new ValidationRuleParser(['state' => 'active']))->explode([ + 'state' => ['required', Rule::in(['active', 'inactive'])], + ])->rules['state']; + $secondRules = (new ValidationRuleParser(['state' => 'inactive']))->explode([ + 'state' => ['required', Rule::in(['active', 'inactive'])], + ])->rules['state']; + $plan = $this->compile($firstRules); + + RulePlanCache::put($firstRules, $plan); + + $this->assertSame(['required', 'in:"active","inactive"'], $firstRules); + $this->assertSame($firstRules, $secondRules); + $this->assertInstanceOf(InlineCheck::class, $plan->checks[1]); + $this->assertSame(CheckType::In, $plan->checks[1]->type); + $this->assertSame($plan, RulePlanCache::get($secondRules)); + } + public function testFlushStateClearsCache(): void { $rules = ['required']; - $plan = RuleCompiler::compile($rules); + $plan = $this->compile($rules); RulePlanCache::put($rules, $plan); $this->assertNotNull(RulePlanCache::get($rules)); @@ -63,9 +89,9 @@ public function testLruEvictionAtMaxSize(): void $rules3 = ['rule_c']; $rules4 = ['rule_d']; - RulePlanCache::put($rules1, RuleCompiler::compile($rules1)); - RulePlanCache::put($rules2, RuleCompiler::compile($rules2)); - RulePlanCache::put($rules3, RuleCompiler::compile($rules3)); + RulePlanCache::put($rules1, $this->compile($rules1)); + RulePlanCache::put($rules2, $this->compile($rules2)); + RulePlanCache::put($rules3, $this->compile($rules3)); $this->assertNotNull(RulePlanCache::get($rules1)); $this->assertNotNull(RulePlanCache::get($rules2)); @@ -73,7 +99,7 @@ public function testLruEvictionAtMaxSize(): void // Adding a 4th entry evicts the least recently used (rules1, // since rules2 and rules3 were just accessed by get() above) - RulePlanCache::put($rules4, RuleCompiler::compile($rules4)); + RulePlanCache::put($rules4, $this->compile($rules4)); $this->assertNull(RulePlanCache::get($rules1)); $this->assertNotNull(RulePlanCache::get($rules2)); @@ -94,9 +120,9 @@ public function testReputtingExistingKeyAtMaxSizeDoesNotEvictAndRefreshesRecency $rules4 = ['rule_d']; $rules5 = ['rule_e']; - $plan1 = RuleCompiler::compile($rules1); - $plan2 = RuleCompiler::compile($rules2); - $plan3 = RuleCompiler::compile($rules3); + $plan1 = $this->compile($rules1); + $plan2 = $this->compile($rules2); + $plan3 = $this->compile($rules3); RulePlanCache::put($rules1, $plan1); RulePlanCache::put($rules2, $plan2); @@ -106,8 +132,8 @@ public function testReputtingExistingKeyAtMaxSizeDoesNotEvictAndRefreshesRecency // eviction loop, rules1 will be wrongly evicted here and rules2 will // not be refreshed to the MRU position. RulePlanCache::put($rules2, $plan2); - RulePlanCache::put($rules4, RuleCompiler::compile($rules4)); - RulePlanCache::put($rules5, RuleCompiler::compile($rules5)); + RulePlanCache::put($rules4, $this->compile($rules4)); + RulePlanCache::put($rules5, $this->compile($rules5)); $this->assertNull(RulePlanCache::get($rules1)); $this->assertNotNull(RulePlanCache::get($rules2)); @@ -118,7 +144,7 @@ public function testReputtingExistingKeyAtMaxSizeDoesNotEvictAndRefreshesRecency public function testPutWithNonStringElementsIsNoOp(): void { - RulePlanCache::put(['required', new stdClass], RuleCompiler::compile(['required'])); + RulePlanCache::put(['required', new stdClass], $this->compile(['required'])); // No crash, and cache is still empty for string rules $this->assertNull(RulePlanCache::get(['required'])); @@ -129,8 +155,8 @@ public function testDifferentRuleArraysAreDifferentKeys(): void $rules1 = ['required', 'string']; $rules2 = ['required', 'integer']; - $plan1 = RuleCompiler::compile($rules1); - $plan2 = RuleCompiler::compile($rules2); + $plan1 = $this->compile($rules1); + $plan2 = $this->compile($rules2); RulePlanCache::put($rules1, $plan1); RulePlanCache::put($rules2, $plan2); @@ -143,8 +169,8 @@ public function testDelimiterCharactersCannotCollideAcrossRuleArrays(): void { $rules1 = ['alpha|beta', 'gamma']; $rules2 = ['alpha', 'beta|gamma']; - $plan1 = RuleCompiler::compile($rules1); - $plan2 = RuleCompiler::compile($rules2); + $plan1 = $this->compile($rules1); + $plan2 = $this->compile($rules2); RulePlanCache::put($rules1, $plan1); RulePlanCache::put($rules2, $plan2); @@ -169,4 +195,12 @@ public static function invalidMaxSizes(): array { return [[0], [-1]]; } + + /** + * Compile rules with the base validator's canonical numeric-rule set. + */ + private function compile(array $rules): AttributePlan + { + return RuleCompiler::compile($rules, self::NUMERIC_RULES); + } } diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php index 22c5ce0cae..0d9c624278 100644 --- a/tests/Validation/ValidationUniqueRuleTest.php +++ b/tests/Validation/ValidationUniqueRuleTest.php @@ -145,6 +145,34 @@ public function testItHandlesNullPrimaryKeyInIgnoreModel() $this->assertSame('unique:table,column,NULL,id_column,foo,"bar"', (string) $rule); } + public function testItPreservesZeroIgnoredIds(): void + { + $this->assertSame( + 'unique:table,column,"0",id', + (string) (new Unique('table', 'column'))->ignore(0), + ); + $this->assertSame( + 'unique:table,column,"0",id', + (string) (new Unique('table', 'column'))->ignore('0'), + ); + $this->assertSame( + 'unique:table,column,"0",id', + (string) (new Unique('table', 'column'))->ignore(0.0), + ); + } + + public function testItNormalizesBooleanWhereValues(): void + { + $this->assertSame( + 'unique:table,column,NULL,id,active,"0"', + (string) (new Unique('table', 'column'))->where('active', false), + ); + $this->assertSame( + 'unique:table,column,NULL,id,active,"!0"', + (string) (new Unique('table', 'column'))->whereNot('active', false), + ); + } + public function testItHandlesWhereWithSpecialValues() { $rule = new Unique('table', 'column'); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index bf90f3551e..34ad495064 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -4551,6 +4551,24 @@ public function testValidateUnique() $this->assertFalse($v->passes()); } + public function testValidateUniquePreservesZeroIgnoredId(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['email' => 'foo'], + ['email' => (new Unique('users', 'email'))->ignore(0)], + ); + $verifier = m::mock(DatabasePresenceVerifierInterface::class); + $verifier->shouldReceive('setConnection')->once()->with(null); + $verifier->shouldReceive('getCount') + ->once() + ->with('users', 'email', 'foo', '0', 'id', []) + ->andReturn(0); + $validator->setPresenceVerifier($verifier); + + $this->assertTrue($validator->passes()); + } + public function testValidateUniqueAndExistsSendsCorrectFieldNameToDBWithArrays() { $trans = $this->getArrayTranslator();