From 52d40c1d06fe12f7932e06c5bd5e72f58b8446dd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:57:02 +0000 Subject: [PATCH 01/22] docs: add components audit remediation plan Record the signed-off classification and remediation strategy for the 0.4 components audit. Capture the accepted fixes, rejected findings, compatibility decisions, and verification requirements as the baseline for the implementation slices. --- ...ponents-04-audit-remediation-plan-codex.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md diff --git a/docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md b/docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md new file mode 100644 index 000000000..7b95030e5 --- /dev/null +++ b/docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md @@ -0,0 +1,320 @@ +# Hypervel Components 0.4 Audit Remediation Plan + +Status: Signed off by `claude-fixes` on 2026-08-22; ready for implementation + +## Objective + +Resolve every genuine issue in the August 18 components audit against the current 0.4 branch. Hypervel 0.4 is greenfield: backward compatibility with earlier Hypervel releases, churn, and patch size are not constraints. Preserve Laravel's current canonical APIs unless Hypervel's coroutine, pooled-resource, or long-lived-worker architecture requires a better contract, or preserving the surface would impose disproportionate machinery or materially worse code. Do not import names, aliases, shims, or duplicate paths retained solely for Laravel's historical backward compatibility. Prefer fail-fast behavior, explicit lifecycle ownership, and source-level fixes over compatibility shims. + +## Final verdict + +- 151 unique findings were substantively valid at audit time. +- 150 unique findings remain open on the current branch. +- Finding 123 was valid but is already fixed on the current branch. +- Findings 4, 9, 14, 93, 109, 119, 129, and 153 require no change: some are false positives, while the rest propose churn or machinery for deliberate behavior that is already correct. +- Finding 88 is an exact duplicate of finding 32 and must not become a second patch. +- Findings 6, 26, 41, 55, 65, 94, 98, 108, 128, 145, 152, 154, and 158 are only partially correct as written. Their valid portions remain in this plan; their invalid portions are explicitly rejected below. +- Some audit statements about Laravel were stale or incorrect. A defect shared with current Laravel remains a defect, but the plan does not cite false upstream parity as evidence. + +## Rejected, duplicate, resolved, and narrowed claims + +| ID | Disposition | Reason | +|---:|---|---| +| 4 | False positive | The audit's claimed Laravel divergence does not exist. Hypervel and current Laravel have the same selector grammar, and runtime checks against both confirmed that raw selectors such as { 1 } and [1, 19] do not match. There is no documented Laravel API requiring the proposed whitespace extension. | +| 6 | Partially confirmed | ViewException::render returning only Response or null is too narrow and must become mixed. ViewException::report returning bool or null is the meaningful Laravel exception contract and must not be widened to arbitrary values. | +| 7 | Confirmed with stale wording | The current cache key is already xxh128, not raw Blade source. The unbounded worker cache and missing source-file recreation after view:clear remain real. | +| 9 | False positive | lastFragment is written but never read. It has no observable behavior and therefore cannot leak a fragment between coroutines. Remove it only if a later compiler cleanup naturally touches the code; do not create a standalone remediation. | +| 14 | False positive | Swoole's response fd is a generated connection SessionId, not an immediately reusable OS socket descriptor. Allocation advances session_round, skips occupied session slots, and verified lookup checks both session and connection identity. The stale-close and delayed-handshake fd-reuse races described by the audit are therefore unreachable. Current Swoole master retains the same invariant, and this investigation exposed no Swoole defect. | +| 32 | Confirmed | This is the canonical keyed-resource-collection issue. | +| 65 | Partially confirmed | There is no missing `x-oauth-2` driver: Hypervel intentionally exposes only the modern OAuth 2 `x` driver and omits legacy Twitter/OAuth 1 support. Current Socialite's `services.x-oauth-2` fallback is a historical configuration alias retained for backward compatibility, not a second driver or modern canonical surface. Remove that fallback and support only `services.x`; do not add an alias or OAuth 1 driver. | +| 88 | Exact duplicate | Same file, cause, behavior, and fix as 32. Cover it with the 32 tests and close both audit IDs together. | +| 93 | Rejected | Moving methods to a preferred class location is style-only churn. Narrowing the concrete path() return to string would needlessly diverge from Laravel's nullable signature and could make existing subclasses incompatible. | +| 94 | Partially confirmed | Native concrete JsonSchema type returns in hypervel/contracts create an invalid reverse dependency. The contract being unbound is not a defect: Laravel likewise does not bind it. Preserve the Laravel contract API by matching upstream's untyped methods with precise PHPDoc; do not add a service provider or binding. | +| 108 | Partially confirmed | Native posix_kill reports failure with false and that result is currently ignored. The catch is not literally unreachable because an overridden signalProcess may throw. Handle both false and Throwable. | +| 109 | Rejected | A metadata-only prescreen can miss a same-size rewrite with restored/coarse timestamps; periodic full rehashing merely delays correctness and adds a tuning constant. Full hashing is the default driver's deliberate correctness guarantee, and finding 105 separately fixes the accidental broad-root scan. | +| 119 | False positive | The defaults on tinker.alias, tinker.dont_alias, and tinker.commands intentionally allow those lists to be removed; TinkerCommandTest explicitly pins that behavior. Typed getters without defaults would break a supported configuration. | +| 123 | Valid, already resolved | RedisConnection::callGet is mixed on the current branch. Retain or extend the serializer regression test, but do not schedule another production change. | +| 128 | Partially confirmed | The strict native type causes the reported TypeError. Match Laravel's clean assertion behavior by accepting null in the JSON assertion methods; do not invent a new undocumented “any errors” meaning for assertOnlyJsonValidationErrors. | +| 129 | Rejected | Framework reset methods are no-throw lifecycle boundaries by design, and the subscriber already preserves the first error across its explicit outer cleanup stages. Wrapping roughly two hundred static resets in per-call fault-isolation machinery optimizes for unsupported throwing reset implementations and weakens the simple at-most-once cleanup contract. | +| 145 | Partially confirmed | The five named defects are real. The aside about adding number, autocomplete, and data-table methods to FormBuilder is an unspecified product expansion, not an audited defect, and is excluded. | +| 152 | Partially confirmed | Keep the token, migration, teardown, and duplicate-resolution fixes. Purely numeric PHP array keys cannot be made string keys by casting because PHP coerces them back to integers, so filter those irrelevant keys from the subprocess environment map. Reject the first-caller descriptor and ConfigContract expansion: supported boot paths initialize the process-global YAML cache consistently and Bootstrapper intentionally requires its concrete Config implementation. | +| 153 | Rejected | Immediate hard termination is deliberate, documented, and pinned by testKillDoesNotWaitForUnrelatedActiveJobs. The timed-out coroutine is not cancellable, so the worker is poisoned; draining siblings delays the hard timeout while the stuck job can continue side effects. | +| 154 | Partially confirmed | Remove the never-supported SQL Server branch and document the Redis script argument. Keep raw usleep: Swoole hooks make it coroutine-friendly, queue tests already override Worker::sleep, and Support\\Sleep adds avoidable allocation/global-fake machinery to polling paths. The 1ms shutdown-only empty check does not justify a new Concurrent notification API. | +| 158 | Partially confirmed | Response::cookies has the reported nullable-return defect. The two protected reusable-client methods are part of Laravel's subclass surface and cause no defect while unused; retain them rather than deleting API for cleanup alone. | + +## Cross-cutting design decisions + +1. Worker defaults and request overrides are different state classes. Laravel-style setters called before `Hypervel\Contracts\Foundation\Application::isBooted()` update a worker baseline. Calls after boot during an execution use CoroutineContext, or a dedicated withX callback performs a scoped override. In standalone use without an Application, retain the documented package fallback; never infer boot from coroutine presence. +2. A pooled client or database connection may be retained only as a pool/factory/resolver handle. A borrowed connection, stream, or client must remain inside its borrow scope. +3. Shared cache publication follows database transaction visibility. The mutating execution bypasses shared caches while its relevant transaction is dirty; shared invalidation happens after commit and never after rollback. Use atomic cache publication where it completely orders fills against mutations; use a per-identity lock only where invalidation cannot publish an authoritative value. Hits remain lock-free. +4. Correctness optimizations must have conservative eligibility checks and a delegated fallback. A fast path may never change database comparison semantics, validation order, or callback behavior. +5. Worker-lifetime caches require a natural finite keyspace or deterministic invalidation. Cheap input-derived values should not be retained worker-wide merely to avoid parsing or hashing them, and arbitrary caps are not a substitute for correct ownership. +6. Fail loudly for unsupported or ambiguous configuration. Do not silently clamp, coerce, fall back to inaccurate readers, or accept an API surface that cannot work. +7. Every concurrency fix needs a deterministic interleaving test, not only a sequential unit test. + +Record the boot-baseline/execution-override semantic shared by Notification, Number, and Sentry once in `src/docs/porting-from-laravel.md`. Record the intentional removal of Socialite's legacy `services.x-oauth-2` configuration fallback there as well. + +## Architecture, compatibility, and cost guardrails + +- Preserve Laravel's current canonical method names, valid-input semantics, contracts, facades, constructor call forms, container aliases, and extension points. Diverge when Hypervel's architecture requires it or when parity would demand disproportionate machinery/workarounds and produce materially worse code; choose the simplest well-adapted contract in that case and document the divergence. Do not inherit aliases, deprecated names, compatibility shims, or implementation residue that current Laravel retains solely for historical backward compatibility; Hypervel 0.4 should expose the modern canonical surface directly. Establish that something is genuinely legacy before removing it—an apparently unused current extension point is not enough. The plan may also reject previously accepted invalid or misleading states with a descriptive exception. +- Apply that rule consistently: Horizon uses `vonage`, never upstream Horizon's stale `nexmo` name; Socialite exposes one `x` OAuth 2 driver configured by canonical `services.x`, without the legacy `services.x-oauth-2` fallback; legacy Twitter/OAuth 1 remains omitted. +- Hypervel worker singletons retain only boot-time immutable/baseline state. Request, job, command, and test overrides belong in CoroutineContext and are cleaned at execution boundaries. This is an architectural adaptation, not a port of Laravel's process-per-request mutable-static assumptions. +- Framework-owned pooled database/auth objects retain resolvers and names, never borrowed connections. The Laravel-compatible direct ConnectionInterface constructor form remains available for non-pooled callers and test doubles. +- Most fixes remove work, bound memory by correct lifetime, preserve a fast path, or add only constant-time validation/state checks. Database-semantic fallbacks in 15-16 run only when the optimization cannot prove equivalence. +- Findings 43 and 57 coordinate only cache misses/mutations; their cache-hit paths remain lock-free. Findings 78-79 replace an unverifiable freshness shortcut with one authoritative row read per existing node at a structural mutation boundary; ordinary tree reads remain unchanged. Reverb recovery is an operator command with zero steady-state cost. Queue timeout behavior remains unchanged because the proposed drain would weaken its safety contract. +- No fix may add periodic polling, arbitrary eviction thresholds, a distributed lock on every request/cache hit, metadata shortcuts with delayed correctness, or a new abstraction whose only purpose is an unsupported failure mode. +- Performance and scalability claims must be measured during implementation for the affected hot paths. A fix does not land if its package benchmark/load test shows a material regression that is not inherent to the required correctness guarantee; revise the design instead. +- Load tests must cover high-cardinality input and long worker lifetimes, not only request latency: retained memory must converge to the natural live/configured keyspace, and temporary request/job state must disappear at execution teardown. +- Compare database queries, remote calls, lock acquisition, bytes copied over IPC, allocations, and coroutine scheduling before and after each affected hot-path fix. Cache hits and ordinary non-strict/read paths must remain lock-free and free of new I/O. +- No synchronous CPU or blocking-I/O work may be moved onto the event loop. Where an existing public API is inherently synchronous, keep its implementation lean and document task-worker/queue offload for heavy workloads rather than hiding an unbounded background mechanism inside the framework. + +## Complete remediation ledger + +Each row is an implementation requirement. Test names are descriptive; use the repository's established test file for that component or create the narrowly corresponding file. + +### Core pools, translation, views, filesystem, websocket, and validation + +| ID | Proposed implementation | Required tests | +|---:|---|---| +| 1 | Change PoolFingerprint's internal canonical-config digest from sha256 to xxh128, matching the repository's non-cryptographic fingerprint convention. Keep canonical key ordering and scalar normalization unchanged. | Exact algorithm and digest-length test; equivalent reordered configuration test; distinct normalized configuration test. | +| 2 | Stop automatically retaining every parsed key on NamespacedItemResolver. Keep setParsedKey and flushParsedKeys exactly as the public explicit cache API, but parse ordinary keys directly; the explode/str_contains work is cheaper and safer than a per-call context lookup or an arbitrary worker cache cap. | Arbitrary validation/translation keys do not grow worker state; explicitly seeded parsed keys still hit and flush; parse output remains identical; a focused microbenchmark confirms the uncached parser is not a material translation regression. | +| 3 | Keep successful translation groups in the worker cache, but store empty/missing locale-group results only in execution-local negative state. This avoids permanent attacker-driven locale growth without repeating filesystem probes inside one request/job. | Thousands of missing locales leave worker loaded state unchanged; one execution probes a missing/legitimate-empty group once; a later execution can discover a newly added translation; positive groups remain worker-cached. | +| 4 | No change; see disposition above. | Preserve existing selector tests. | +| 5 | Extract `Translator::get()`'s lookup body into a protected internal method with separate substitution replacements and missing-key-callback replacements. `get()` passes `$replace` for both; `choice()` passes `[]` for substitution and the caller's `$replace` to the missing callback, then substitutes only after plural segment selection. Keep every public signature unchanged and do not duplicate lookup. | Callback receives locale, key, and exact replacements; a replacement containing a pipe does not alter plural selection; normal translation replacement remains once-only. | +| 6 | Widen ViewException::render to mixed and forward all native Laravel exception render results. Keep report as bool or null. | String, array, View, Responsable, Response, and null render forwarding; bool/null report forwarding; original exception behavior when methods are absent. | +| 7 | Retain the worker cache only for existing named views, whose keyspace is application-defined. For raw inline component source, derive the deterministic xxh128 view name and keep only execution-local reuse; ensure the source file exists on the first use in that execution. Make view:clear clear the execution-local marker so an immediate re-render recreates the source. Do not add an arbitrary eviction cap. | Named views reuse worker state; thousands of unique inline sources do not grow the worker map; repeated inline render in one execution avoids repeated stats; delete/view:clear then render recreates the source; no cross-component collision. | +| 8 | Preserve the public abstract Engines\Engine base, but make getLastRendered return nullable string to match its initialized state. | Anonymous concrete subclass returns null before render and the rendered path afterward. | +| 9 | No standalone change; see disposition above. | None. | +| 10 | Remove the s3/gcs client-only match arms from whole-disk pool definitions. A custom whole-disk creator fingerprints the logical disk name plus its complete normalized config unless it explicitly supplies its own fingerprint. Built-in S3/GCS client pools keep their client-specific fingerprints. | Two custom S3 or GCS creators with identical client credentials but different bucket/root/name never share a disk pool; built-in clients still share only when safe; explicit fingerprint override works. | +| 11 | Wrap the positioned resource with GuzzleHttp\Psr7\Utils::streamFor, GuzzleHttp\Psr7\LimitStream, and StreamWrapper::getResource so the base readStreamRange enforces its end offset without buffering or a custom stream implementation. Add `guzzlehttp/psr7` as a direct filesystem dependency instead of relying on `hypervel/http` to provide it transitively. | Closed, open-ended, and suffix ranges; seekable and non-seekable sources; zero/one-byte boundaries; returned value remains a PHP resource; close propagation; nested leased pooled stream remains borrowed until wrapper close; standalone filesystem dependency/autoload check. | +| 12 | Model signed-route ownership separately from a scoped/on-demand adapter's serve flag. Named scoped disks over a served parent use the parent's route and accumulated prefix; nested scopes compose prefixes. Anonymous build disks cannot advertise the global named route and must fail clearly. | Download and upload temporary URLs through one and nested scopes; signatures validate; anonymous served build fails clearly; unserved and base disks unchanged. | +| 13 | Document that Hypervel Filesystem::hash defaults to xxh128 while Laravel defaults to md5, including the porting implication and explicit-algorithm escape hatch. | Documentation review plus existing/default and explicit hash algorithm tests. | +| 14 | No production change; the dependency evidence and Swoole conclusion are recorded above. | No framework regression test is needed for a dependency invariant. Keep the existing handshake/close lifecycle tests. | +| 15 | Compile a conservative validation batch-eligibility plan. Batch a database presence rule only when no preceding executable rule can reject or transform the value; allow only proven metadata flags before it. Delegate all other attributes to normal ordered validation. Put any new rule-category metadata beside Validator's existing implicit/dependent/size rule categories and share it with the compiler rather than creating another free-floating list. | integer/regex/bail/custom-rule/subclass failures never query invalid raw data; order matches Laravel; eligible exists/unique rules still issue one batch query; PostgreSQL invalid input produces validation failure, not QueryException; compiler and delegated paths consume one category definition. | +| 16 | Treat precomputed presence results as an optimization, not semantic authority. On each precomputed miss or ambiguous multi-count, delegate that probe to the original presence verifier so the database collation/coercion decides; a group-level “all fetched values occurred in the input” check is insufficient when differently-cased inputs coexist. Memoize delegated results only within the validation execution. This is a data-integrity defect for unique rules: a bytewise miss can falsely pass a duplicate that the database collation considers equal. | MySQL/MariaDB and PostgreSQL cases for case folding, trailing spaces, numeric coercion, exists and unique; unique:users,email rejects a differently-cased duplicate under case-insensitive collation; two differently-cased inputs in one batch; exact hit stays query-free; fallback issues one delegated query per distinct missed/ambiguous value, with worst case equal to the unbatched path. | +| 17 | Pre-evaluate an exclusion rule only when it is the first executable rule, ignoring only metadata flags. Otherwise let the ordinary rule loop preserve Laravel order and bail semantics. | integer before exclude_if still fails integer; exclusion-first omits data; bail and dependent exclusion variants; delegated and compiled paths agree. | +| 18 | Substitute conditional-field wildcards using the validator's explicit wildcard captures for the current attribute, not every numeric path segment. | Numeric literal segments, one and multiple wildcards, mismatched wildcard counts, nested arrays, and escaped-dot fields. | +| 19 | Reset numericRules to its default before every inline validation check, matching the delegated path. | Numeric then string-valued checks in both orders; wildcard attributes; message selection remains isolated per attribute. | +| 20 | Remove AttributePlan::$required, $hasImplicitRule, the stored unused size mode, and the duplicated implicit-rule list/method. Keep one compiler source of truth and behavior-oriented tests. | Compiler output contains only consumed fields; implicit, required, nullable, and size-dependent behavior remains covered without reflection-pinning dead fields. | + +### Mail, notifications, gRPC, collections, and support + +| ID | Proposed implementation | Required tests | +|---:|---|---| +| 21 | Widen Mailable metadata values and storage shapes to int\|string\|null, matching Envelope. Cast consistently only where a downstream header API requires a string. | Integer and string metadata through send, render, and assertion helpers; null/absent metadata; strict-types regression. | +| 22 | Add only the missing `mail` driver to MailManager's existing poolable transport list. Its default `sendmail -bs` transport then follows the same borrow/isolation path already used by the explicit `sendmail` driver; do not introduce a second transport wrapper or command-mode abstraction. | `mail` is proxied by default and can accept explicit pool options; its interactive stream is reused only within one borrowed transport and concurrent sends cannot share a borrow; existing `sendmail` modes and fingerprints remain unchanged. | +| 23 | In hasEnvelopeAttachment, call attachments only when the mailable defines it; otherwise use an empty list. | Envelope-only mailable, mailable that also defines attachments, attachment match/no-match, and no method fatal. | +| 24 | Use one CoroutineContext path in every execution mode. Store under one package key a `WeakMap` keyed by transport object identity; this avoids `spl_object_id` reuse, isolates instances, lets dead transports disappear, and relies on CoroutineContext's existing non-coroutine fallback instead of branching on coroutine presence. Preserve all messages and flush semantics within one execution. | Sibling requests and separate transport instances cannot see each other's messages; flush is local; destroyed transports disappear from the non-coroutine weak map; many completed contexts leave no worker accumulation; non-coroutine test usage remains deterministic. | +| 25 | Make ChannelManager's Laravel-style deliverVia and locale setters lifecycle-aware: before `Application::isBooted()` they update worker baselines; after boot during an execution they update context overrides. Do not add a package-specific boot predicate. | Provider boot defaults are inherited by later request/job coroutines; request overrides do not affect siblings or the next execution; explicit notification locale still wins; flush resets both layers; shared porting-guide entry documents the semantic. | +| 26 | Keep AnonymousNotifiable::getKey returning null for Laravel fake/assertion parity, but make BroadcastNotificationCreated throw a descriptive exception when no explicit broadcast route exists instead of constructing a trailing-dot private channel. The audit's upstream comparison was wrong—Laravel also defines getKey—but the silent malformed-channel behavior remains a defect. | Anonymous broadcast without route fails loudly; explicit broadcast route works; normal model notifiable fallback unchanged; getKey remains null. | +| 27 | Check pre-transport deadlines before registering pending work and outside the native connection-error catch. Fail only the call with deadline status; retain the healthy connection. Keep actual mid-write native failures connection-fatal. | Deadline expires between scheduling and send; connection remains usable by a later call; expired write path classification; genuine write failure still terminates the connection. | +| 28 | Reject raw Swoole options that override first-class TLS ownership: ssl_verify_peer, ssl_cafile, ssl_cert_file, ssl_key_file, ssl_passphrase, and ssl_host_name. Continue allowing unrelated native options. | Every owned key conflicts clearly; first-class TLS values cannot be bypassed; unrelated ssl/native settings remain accepted; plaintext configuration unaffected. | +| 29 | Replace FILTER_VALIDATE_DOMAIN with an explicit resolvable service-name grammar that accepts underscores while rejecting whitespace, empty/malformed labels, invalid IP literals, and malformed ports. | Docker/Kubernetes-style underscore names; DNS names and IPv4/IPv6; invalid labels, whitespace, bracket, and port cases. | +| 30 | Add symfony/polyfill-php86 as a direct collections dependency because SortDirection is used by that split package. | Package metadata assertion and a standalone collections install/autoload smoke test without database. | +| 31 | Cast the single-item Arr::join result to string, matching the multi-item path and native return type. | One integer, float, stringable object, string, and multi-item list. | +| 32 | Use first() when guessing a resource collection class instead of reading items[0]. | keyBy, filtered/gapped keys, ordinary list, empty collection failure, and paginator/resource conversion. Finding 88 closes with these tests. | +| 33 | Preserve Number::useLocale/useCurrency as lifecycle-aware Laravel-compatible setters: use the current `Application::isBooted()` from Container when available, update static worker defaults before boot completes or in standalone use without an Application, and set context overrides after boot during an execution. Implement withLocale/withCurrency as explicit scoped context operations with reliable restoration. | Provider boot locale/currency inherited by requests; sibling overrides isolated; nested withLocale/withCurrency restore after success and exception; no-application CLI baseline; flush resets static and context state; shared porting-guide entry documents the semantic. | +| 34 | Port current Laravel's array-capable multibyte Str::substrReplace implementation, including array offset/length/replacement behavior and key preservation. Correct the scalar negative-length calculation as part of the port: the current Str::substrReplace('Hello', 'X', 2, -1) produces HeXello instead of HeXo. | Scalar parity including the explicit negative-length example; arrays with scalar and array replacements; offset/length arrays; associative keys; negative offsets and lengths; multibyte strings; mismatched replacement lengths. | +| 35 | For built-in UUID/ULID codecs, identify binary values by the unambiguous 16-byte storage length and validate textual 36/26-byte forms separately. Leave the generic public BinaryCodec heuristic available to custom codecs. Runtime sampling confirmed that roughly one in sixteen thousand random v4-shaped UUID payloads can be valid UTF-8 and NUL-free, so this is ordinary data loss at scale rather than a purely theoretical collision. | Deterministic valid-UTF-8, NUL-free 16-byte UUID/ULID payloads round-trip through casts and database bindings; a fixed previously misclassified v4 payload; textual forms; invalid lengths; custom codec behavior unchanged. | + +### Scout, permission, Sentry, Telescope, and foundation + +| ID | Proposed implementation | Required tests | +|---:|---|---| +| 36 | Render finite numeric filter values with Algolia's documented numeric comparison syntax, including equality as `field = 42`; the current facet form `field:42` is wrong for typed numeric attributes. Render strings as escaped facet filters and booleans with boolean facet syntax. Apply the same typed formatter to where, whereIn, and whereNotIn. Reject NaN and infinities. | Exact outgoing filter expressions for int, float, numeric-looking string, ordinary string, bool, backed enum, negative values, lists, escaping, NaN, and infinities. | +| 37 | Replace one Swoole defer per Scout job with one execution-local FIFO queue and one owner/drainer defer. Never coalesce operations whose ordering changes semantics. | Save then delete stays FIFO and cannot resurrect; delete then save; multiple models; one registered defer; exception handling leaves deterministic remaining work and cleans context. | +| 38 | Defer the FIFO only while an HTTP RequestContext is active. Without one—including console, seeders, and queue jobs—execute each non-queued Scout operation immediately in Laravel order; there is no response-latency benefit to retention there. Do not invent a queue threshold. | HTTP save/delete drains FIFO at execution end; long console/seeder/queue-job loops retain no deferred collections and perform operations incrementally; import behavior unchanged; network operation count is unchanged. | +| 39 | For Typesense take pagination, keep one fixed engine-valid per_page for every requested page so page offsets remain stable, collect until the requested take is covered or results end, then truncate the final collection. At most one page may be over-fetched. | take across one/multiple pages has no duplicates or gaps; fixed outgoing per_page; exact truncation; short page and found exhaustion; at most one excess page. | +| 40 | Remove the silent Typesense per-page clamp. Validate the documented Typesense maximum before sending and throw a descriptive InvalidArgumentException when callers request more than the engine supports, so paginator metadata can never disagree with the query. | Exact-limit pagination; over-limit failure before network call; page/current/total metadata for valid values; simple and length-aware paths. | +| 41 | Restrict the primary-key integer fast path to models whose Scout key name equals their Eloquent primary key name. Within that path, use the primary key name, type, and qualified column consistently, and validate decimal strings against PHP_INT_MAX without casting first. Models with a custom Scout key use the normal search path; there is no getScoutKeyType API from which to infer a safe custom-key optimization. The audit's claim that upstream consistently used getKeyName was wrong, but the mixed identities are still semantically broken. | PostgreSQL overflow string produces no integer-cast query error; max boundary; ordinary integer-primary-key fast path; custom Scout key name bypasses the primary-key optimization; string/UUID primary key; result identity mapping. | +| 42 | Assign the callback-transformed raw result before mapping models and before computing total/hasMore. Make rawResult, models, and paginator metadata describe the same payload. | Callback changes hits and count; length-aware and simple pagination; raw result exposure and mapped models agree; unchanged callback path. | +| 43 | Centralize permission cache settlement on the mutation's actual connection. Outside a transaction, invalidate immediately. Inside one, clear current execution hydration, mark affected catalog/model keys dirty so reads bypass shared and execution memos, and invalidate shared keys only after commit; rollback only clears dirty/runtime state. Serialize assignment-cache misses and their exact committed invalidations with per-key locks; hits remain lock-free. Keep nested rollback bookkeeping scoped to the actual transaction record. Do not replace exact invalidation with the existing partition-global assignment token: shared keys already include that token, and bumping it per model mutation would invalidate every model in the partition and orphan old entries until TTL. Validate lock support only when this cache is enabled. | The mutating transaction sees its own grant/revoke without publishing it; concurrent executions keep pre-commit state; commit invalidates even after a forced stale re-prime; rollback never publishes uncommitted rows and restores fresh reads; fill-vs-commit barriers; nested savepoint rollback; catalog, role, permission, team, partition, and pivot paths; cache hits perform no lock or new remote operation; cold-fill lock cost is measured. | +| 44 | Add an execution-local memo for hydrated direct permissions, parallel to the via-role memo. Memoize only non-loaded hydration and clear it from every model permission/assignment invalidation path. | One hasPermissionTo call hydrates direct permissions once; repeated calls query once; invalidation, team change, partition change, and sibling execution isolation. | +| 45 | Apply role team filtering in the shared fallback/filter path so every catalog bypass retains the current-team boundary, including both a requested role-class mismatch and the complex-parameter/catalog fallback. Include only global roles and roles for the current team. | Same role name on another team never matches through either fallback; class-mismatch and complex-parameter cases; current-team and global roles match; null-team behavior follows the explicit policy from 47. | +| 46 | Use the cached catalog when the configured role class is compatible with the requested base class. For a genuinely incompatible valid class, memoize one class/partition catalog per execution and then apply current-team filtering. | Compatible subclass uses the shared cache; incompatible class makes one query per execution; invalidation clears it; team and cache partition separation. | +| 47 | Make team-scoped writes fail early with a named package exception when no team is selected, matching the shipped non-nullable assignment pivots. Keep null-team reads fail-closed as an empty relation so checks cannot leak assignments from another team; document that nullable team IDs apply to global Role records, not subject-assignment pivots. | assignRole, givePermissionTo, sync operations, queued model writes, and direct pivot paths with no team; configured team success; null-team reads return no subject assignments and never broaden the query; exception and docs name the missing team context. | +| 48 | Port both Sentry Monolog handlers fully to Monolog 3 LogRecord APIs. Use isHandling and Level comparisons; create modified records with LogRecord::with; make doWrite consume LogRecord and strip exception data from a local context copy. | Single and batch records; highest handled level; context enrichment survives; exception context is handled once; below-threshold records drop; immutable original record remains valid. | +| 49 | Give Hub an explicit mutable baseline Scope and consult `Application::isBooted()` through the Application contract. `configureScope` before boot completes mutates the baseline; after boot during an execution it mutates the cloned context scope. Do not add a Sentry-specific boot predicate or infer lifecycle from coroutine presence. | AppServiceProvider/Sentry provider boot tags and user data appear in later requests; sibling request mutations are isolated; nested push/pop scopes; queue/console/HTTP execution entry; flush resets baseline and context; shared porting-guide entry documents the semantic. | +| 50 | Null-guard internal trace frames in sentry:test before reading filename or line. | Trace containing internal/null-file frame; ordinary frame formatting; command still sends the diagnostic event. | +| 51 | Make commandFinished flush event buffers without waiting for every transport-wide in-flight request. Reserve the blocking transport drain for worker shutdown. | Command completion does not wait for an unrelated request send; buffered telemetry is scheduled/flushed; worker shutdown still drains; transport failures remain reported. | +| 52 | Add one small JSON-normalization helper and use it only at Telescope call sites that currently encode/decode arbitrary observed values and can throw. Apply the native invalid-UTF-8/partial-output flags and return a valid normalized value; do not build a general recursive graph serializer. | Each actual event/job/request/view/dump/model throw site with invalid UTF-8 and non-finite values; valid payloads remain byte/shape compatible; watcher dispatch does not escape because of normalization. | +| 53 | Invoke every afterStoring hook with foreach/each rather than Collection::every, which treats void as false. Preserve the chosen exception policy explicitly. | First hook returns void and all later hooks run; false return does not stop hooks; throwing hook behavior and reporting are pinned. | +| 54 | If a dump should be shown but Telescope is not recording the current execution, delegate to the previous dump handler. Only consume output when Telescope actually records it. | Dashboard/always-record flag in ignored execution delegates; active recording stores once; disabled dump watcher delegates; no duplicate output. | +| 55 | Keep the cheap process-lifetime memory_get_peak_usage value and Laravel-compatible memory payload key, but label it in the UI/docs as the worker memory peak. Do not add request-delta bookkeeping: concurrent coroutine allocations make that number neither a request peak nor reliably attributable. | Payload key remains compatible; UI/docs say worker peak; value remains monotonic process telemetry; no request context state or extra measurements are introduced. | +| 56 | Cast non-array Stringable and UriInterface inputs to string before the uri helper's route/string dispatch. Preserve the array route form. | Stringable, league URI, plain string, route array, and invalid unsupported object. | + +### Sanctum, Fortify, Socialite, JWT, Passkeys, Inertia, Saloon, and nested set + +| ID | Proposed implementation | Required tests | +|---:|---|---| +| 57 | Store token/tokenable cache values in a small package-owned presence envelope so the normal Cache Repository contract can distinguish absent from cached-null without using its internal raw/sentinel API. Define an explicit cache-store capability for truly atomic `add()` and implement it only on the existing model-safe stores whose operation is atomic; after the existing model/serializer safety validation, require the selected Sanctum store itself to expose that capability. This rejects multi-layer Stack, memoized, Storage, and fallback get/put publication without widening the fail-closed custom-store policy. Retain the existing boot-time positive-TTL validation. On a cold fill, read, query, atomically add a positive or negative envelope for the configured TTL, and if add loses, reread and honor the winner. After commit, create/update overwrites with authoritative fresh state (reload tokenable after an ownership-changing update), while delete/bulk delete overwrites both keys with negative envelopes; an in-flight stale add therefore cannot resurrect revoked state. Remove `updateLastUsedAt`'s duplicate snapshot re-put and let the updated lifecycle publish once. Do not require locks or invent a shorter tombstone TTL. | Deterministic token-miss-vs-delete, tokenable-fill-vs-delete, last-used-vs-delete, create-vs-negative-fill, and update-vs-stale-fill barriers; once revocation returns, later auth is denied; transaction commit/rollback and bulk delete; add loser honors the authoritative winner; Redis, database, file, and Swoole stores pass both safety and atomic-add validation; Stack, memoized, Storage, fallback-only, and unsupported custom stores fail at boot; zero/negative TTL retains its existing boot failure; cache hits perform no new operation; mutation and cold-fill operation counts. | +| 58 | Add a dedicated per-guard actingAs context override. Guard::user and hasUser consult it before bearer-token keyed caches; forgetUser clears the override and ordinary token caches. | actingAs wins even with Authorization bearer header; per-guard and sibling isolation; forget restores normal token auth; ordinary tokens unchanged. | +| 59 | Port and register the named two-factor limiter normally, without a conditional-existence guard, and key it by the challenged login/account identity stored in session. Application providers boot later and RateLimiter::for naturally overwrites the package default. If the login identity is unexpectedly absent, fall back to the current session ID and then IP rather than putting every malformed flow in one null bucket. Make the default pipeline use the named limiter. | Same account across IPs shares the cap; different accounts on one IP do not; missing login identity isolates by session/IP; later application registration overrides naturally; successful challenge behavior. | +| 60 | Return an empty recovery-code list for null or empty encrypted storage. Treat it as normal challenge failure while allowing malformed encrypted ciphertext to remain a loud configuration/data error. | Two-factor disabled between login and challenge; empty/null codes; locked refetch; malformed encryption still throws the correct exception. | +| 61 | When force-enabling two-factor rotates the secret/recovery codes, clear two_factor_confirmed_at in the same write whenever confirmation is enabled. Non-forced enable remains unchanged. | Confirmed user forced rotation requires confirmation again; non-force preserves state; confirmation feature disabled; write atomicity. | +| 62 | Store the raw redirect Closure\|string in the cached provider baseline and lazily resolve it at most once per execution against the current request URL service. Absolute literals can stay on the direct path. Do not evaluate request-derived closures while building a worker-cached provider. | Two hosts/tenants through one cached provider get their own callback URLs; closure/url resolution runs once per execution; concurrent requests cannot poison each other; absolute redirect has no context overhead. | +| 63 | Centralize absolute/relative/closure redirect normalization in getRedirectUrl. setConfig and redirectUrl store raw execution overrides, invalidate that execution's resolved value, and pass through the same resolver. | Relative and absolute redirects from base config, setConfig, redirectUrl, and closures; current request origin; override invalidation; sibling isolation. | +| 64 | Pass the configured id_token_alg, defaulting to RS256, as Firebase JWK::parseKeySet's default for keys that omit alg. Let the JWT library validate algorithms/signatures; do not add a second maintained whitelist. | JWKS key without alg; key with alg; configured override; library rejection of unsupported/mismatched algorithms; key rotation/cache refresh; signature and claim failures. | +| 65 | Keep the single modern `x` OAuth 2 driver, but remove the `services.x-oauth-2` configuration fallback retained by Socialite for historical compatibility. Read only canonical `services.x`; do not add an `x-oauth-2` alias or restore Twitter/OAuth 1. Keep the package docs and shared porting guide consistent with that one modern name and configuration path. | `x` resolves only from `services.x`; legacy `services.x-oauth-2` alone does not configure it; `x-oauth-2` and `twitter` driver names remain unsupported; package, user, and porting documentation describe only the one modern OAuth 2 driver and key. | +| 66 | Always route logout with a token through manager invalidation. Let the existing blacklist-disabled exception abort before logout events or context clearing. | Blacklist disabled throws and the authenticated user remains; no Logout event; enabled blacklist invalidates and clears normally. | +| 67 | After successful invalidate, clear cached payload and user entries for that token while retaining the token identity so the next decode observes the blacklist. | Same-execution user/getPayload/check fail after invalidation; grace-period behavior; other tokens unaffected. | +| 68 | jwt:secret writes only JWT_SECRET. Never create or overwrite JWT_ALGO; the shipped config already supplies HS256 when no environment override exists. | Missing algorithm uses config default; existing RS/ES algorithm survives force and confirmation paths; secret replacement behavior. | +| 69 | Scope the delete-route passkey lookup to the authenticated user's passkeys, preserving the configured model's route-key behavior. Foreign and nonexistent identifiers both become the same ModelNotFound response before DeletePasskey runs. | Own, foreign, and nonexistent identifiers with identical 404 response for the latter two; custom route key/model and morph owner; unauthenticated flow. | +| 70 | Apply the package's passkeys throttle middleware to destroy exactly as to the other passkey mutation routes, and omit it when configured null. | Default throttle on destroy; custom limiter; null disables; route middleware snapshot. | +| 71 | Split Inertia callable resolution by prop contract. Deferred, Optional, and Once props invoke their wrapped callable through the container unconditionally; Merge, Always, Scroll, and generic data preserve callable-looking arrays/strings as data unless they are callable objects intended for invocation. | Callable arrays, static method strings, closures, invokable objects, and callable-looking data across every prop wrapper; each true callable invoked exactly once. | +| 72 | Apply partial-reload inclusion before traversing dot-notation props. Excluded entries are dropped without resolving their leaf; included descendants resolve only the intermediate structure required to reach them and then flow through normal prop resolution. | Excluded dot closure never runs; included closure runs once; multiple included descendants; parent/child collisions; Arrayable/lazy intermediate values. | +| 73 | Add X-Inertia: true to reload requests and make AssertableInertia parse a JSON page for Inertia responses while retaining view parsing for non-Inertia responses. | Real middleware reload, 409 location response, 303 redirect normalization, already-loaded page, partial headers, and legacy view response. | +| 74 | Widen the inertia helper's accepted value to include ProvidesInertiaProperties, matching Factory, and update imports/docs. | Provider object, array, closure, and property context conversion. | +| 75 | Make replaceHeaders replacement case-insensitive and have all authenticators replace Authorization rather than append it. | Default auth then refresh; mixed casing; exactly one newest Authorization value; unrelated headers retained. | +| 76 | Track pages yielded independently from page numbers. Reset the counter for each iteration/pool, increment after a yielded response, and enforce maxPages from that counter for startPage 0 or 1. | Start pages 0 and 1; max 0, 1, and N; iterator and pool request identical pages/counts; repeated iteration resets. | +| 77 | Correct Saloon docs: replaceHeaders replaces matching header names and retains unrelated headers. | Documentation review plus 75's behavior tests. | +| 78 | Remove the freshness-revision optimization and make execution of each pending structural action inside the model save/delete/restore lifecycle the sole database-preflight owner. Relationship fluent setup (`appendOrPrependTo`, `beforeOrAfterNode`, and related helpers) keeps its current/upstream in-memory assertions for immediate feedback but performs no reload; helpers such as `saveAsRoot` that must decide from persisted position defer that decision to the lifecycle boundary. At action execution, action-specific preparation reloads every participating existing node's mutation identity/structural columns once from the write connection, re-asserts against that fresh snapshot, and mutates from it. Remove duplicate `ensureMutationIdentityIsLoaded`/`refreshNodeForMove` preflights, while retaining post-write refreshes where the public API promises an updated target/model and the result is not derivable. This restores the upstream nested-set action shape and improves merge fidelity instead of retaining a Hypervel-only correctness-critical revision cache. A retry after rollback cannot reuse coordinates from the rolled-back attempt. Remove `NodeFreshness` and all revision/rollback bookkeeping; retain structural-identity comparison in a focused `NodeIdentity` helper. | Closure deadlock retry after final structural write; commit-time serialization failure retry; sequential/nested savepoint rollback; successful commit; invalid relationship setup still throws immediately without querying; `saveAsRoot` decides from its action-boundary snapshot; each participating existing node performs exactly one pre-mutation authoritative row read per executed action; fresh action assertions catch changes after fluent setup; query logging distinguishes and preserves required post-write refreshes; new nodes and ordinary reads add none. | +| 79 | Use the same stateless action-boundary preparation as 78. An execution-local clock misses child writes, while a worker-local clock still cannot observe another process and therefore cannot prove a retained model current. Reloading means coordinates always come from the write connection immediately before the package's mutation logic, and no retained in-memory freshness state is trusted across coroutines, rollbacks, or retries. It does not serialize concurrent writers or close the race between that read and the write; continue requiring application-level serialization to the same table/scope exactly as documented. Bulk `fixTree`/`fixSubtree`/`rebuildTree`/`rebuildSubtree` paths retain their one wholesale snapshot and internal direct-assignment/save path and never add per-node preflights. | Parent-child and sibling-coroutine sequential mutation interleavings; a model passed through a child; retry uses the latest write-connection snapshot; an intentionally unserialized concurrent-writer test/documentation fixture demonstrates that serialization remains required; bulk repair/rebuild query counts stay O(1) reads rather than N+1; no freshness state remains; benchmark the one preflight read within representative serialized structural writes. | +| 80 | Share relation precondition logic: unsaved parents produce an empty relation, but persisted models missing bounds, parent, or scope columns throw LogicException. Apply it to lazy constraints and eager model preparation for descendants, ancestors, and siblings. | Partial select lazy relation, eager load, and destructive relation call all fail loudly; each missing structural field; unsaved model remains empty. | + +### Database, image, collections duplicate, pagination, JSON Schema, and API client + +| ID | Proposed implementation | Required tests | +|---:|---|---| +| 81 | Override MySqlConnection::resetForPool, call the parent reset, and clear lastInsertId. | Two consecutive pool borrow windows; first inserts and sees ID, second sees null before inserting; discard/reset paths. | +| 82 | Apply incrementEach's strict string-column and numeric-amount validation to decrementEach before constructing raw SQL. | Malicious SQL fragment and nonnumeric amount rejected before query; non-string/associative shape failures; valid ints, floats, and numeric strings update correctly. | +| 83 | Add one package-internal MIME buffer helper with a lazily initialized worker-static finfo and use it from Image and InterventionDriver. finfo::buffer is stateless and non-yielding, so no DI service, coroutine state, lock, reset method, or AfterEachTestSubscriber registration is needed; retaining the handle for the worker lifetime is the intended ownership. | Both call sites report identical MIME across repeated processing; invalid data behavior; ordinary image tests remain independent without a reset seam. | +| 84 | For HEIC/HEIF, if the selected driver cannot decode dimensions, throw ImageException with the driver exception as previous. Never fall back to the known-inaccurate native reader. | Driver success; driver failure with previous exception; native reader is not called for HEIC; ordinary image fallback unchanged. | +| 85 | Replace dimension and effect clamps with consistent InvalidArgumentException validation matching the declared ranges. Invalid dimensions are caller input errors, not image-decoding failures. | Zero/negative width and height for cover/contain/crop/resize/scale; blur/sharpen below 0 and above 100; exact valid boundaries. | +| 86 | Detect unsupported driver names before delegating. Let exceptions from registered custom creators propagate unchanged and validate that the creator returned a Driver with a descriptive result-type error. | Unknown driver message; custom creator InvalidArgumentException preserved; wrong return type; valid extension. | +| 87 | Document that decode/transform/encode are synchronous CPU work that block the worker event loop, and direct heavy conversions to task workers or queued jobs. Do not invent unsupported numeric thresholds. | Documentation review against image driver behavior and worker/task terminology. | +| 88 | No second implementation; close with 32. | 32's tests. | +| 89 | Amend pagination README so current_page_url is documented for both simple and length-aware paginator JSON. | Documentation review plus existing serialization snapshots for both paginator types. | +| 90 | Remove CursorPaginator's duplicate hasMore property and retain the abstract declaration. | Reflection confirms one declaration; cursor pagination behavior unchanged. | +| 91 | Pass this paginator's pageName to resolveCurrentPage when direct construction receives a null page. | Custom p resolves p rather than page; default page; explicit page bypasses resolver. | +| 92 | Remove pagination's hard runtime requirements on database and http. Keep them in require-dev and add Composer suggests only if the optional model/resource transformations need explanation. | Standalone pagination Composer install/autoload; metadata has no cycle; model/pivot/resource instanceof paths still work when optional packages are installed. | +| 93 | No change; see disposition above. | Preserve nullable-path and state-flush behavior. | +| 94 | Match Laravel's Contracts\JsonSchema\JsonSchema shape: keep the public interface, remove its concrete native return types/imports, and express the concrete types only in fully-qualified PHPDoc. JsonSchemaTypeFactory continues to implement it with covariant native returns. Do not add a binding; neither Laravel nor Hypervel documents container injection for this factory. | Standalone hypervel/contracts install/autoload and third-party contract implementation without json-schema installed; concrete factory covariance; static JsonSchema entry API; reflection confirms the Laravel-compatible method surface remains present. | +| 95 | Serialize schema types with ordered instanceof dispatch from most specific to base type. Keep extension open rather than marking all type classes final. | Subclasses of every supported type serialize; most-specific subclass route wins; unsupported unrelated type still fails. | +| 96 | Implement the honest representable subset of JSON Schema 2020-12 ref siblings: add absent assertions, accept identical assertions, and allow annotation siblings such as title/description/default to overlay. If the target and sibling provide different values for the same assertion keyword, throw a descriptive unsupported-conjunction exception rather than silently weakening either schema or building partial allOf machinery. Document this lasting conjunction boundary in the json-schema package docs. | Added/identical assertions; annotation overlay; differing scalar/required/properties assertions fail loudly; the prior outer-wins weakening is impossible; reference cycles and missing refs; documentation matches the supported subset. | +| 97 | Resolve anyOf branches once, retain the resolved tuples, and pass them to nullable/general normalization without a second ref traversal or node count. | Nullable union near MAX_NODES; an injected counting resolver double proves one lookup/count pass without production instrumentation; ordinary union and cycle behavior. | +| 98 | Correct only the stdClass branch error so it names the unsupported schema fragment. Keep Serializer::$ignore as a protected static extension point; converting it to a constant is style churn and could break subclasses. | Exact meaningful error for invalid property/branch stdClass; valid properties map; subclass customization of ignored keywords remains possible. | +| 99 | Make asJson and asForm call ensureStructuredMutationAllowed. GET and HEAD structured conversion throws consistently even when parsed query data is present; raw withBody remains the explicit body API. Update API-client docs. | Empty and query-bearing GET/HEAD; POST/PUT conversion; raw body on GET/HEAD; no duplicated query body. | +| 100 | Move the API bridge out of beforeSending and register it as the first internal HTTP middleware when the underlying PendingRequest is created, after the base prepared-body tracker but before every user Guzzle middleware. It constructs the normal Http Request wrapper (including structured data/attributes), runs API request middleware once, stores activeRequest, and forwards its PSR request. This is one bridge layer replacing the existing callback, so legitimate cache/circuit-breaker short-circuits still have context without extra steady-state middleware. | Cache/circuit-breaker middleware short-circuits after the API bridge without a null fatal; API request/response middleware run once; exact ordering against user and beforeSending middleware; body/data/attribute mutations; normal, retry, and async-disabled sends; defensive invariant error. | +| 101 | Make both mutable builders—`Hypervel\ApiClient\PendingRequest` and `Hypervel\Http\Client\PendingRequest`—implement SelfBuilding with public static `newInstance(): static` returning a fresh instance. Both have container-resolvable all-default constructors and otherwise become unsafe auto-singletons; Saloon's connector-required request is not affected. | Two container resolutions of each class are distinct; concurrent HTTP builders do not share options, middleware, callbacks, cookies, promises, or fakes; concurrent API builders do not share middleware, client, context, or active request; newInstance and documented factory/facade paths. | +| 102 | Add ApiResource::__set that throws LogicException and align property and array mutation messages with their syntax. | Property assignment and unset; offset set/unset; no dynamic shadow; reads and toArray/toJson remain identical. | + +### Database worker safety, watcher, Reverb, Wayfinder, and Tinker + +| ID | Proposed implementation | Required tests | +|---:|---|---| +| 103 | When missing-attribute prevention is disabled, keep offsetExists on the direct path with no context work. When enabled, wrap getAttribute in an execution-local suppression depth consulted only by the exceptional missing-attribute branch, restoring in finally and supporting nesting. This is execution state, not a boot/default setter, so `Application::isBooted()` is intentionally irrelevant. | Two forced interleavings cannot disable strict mode for a sibling or permanently; lazy relation yield; nested isset; custom missing-attribute callback; ordinary non-strict benchmark stays on the direct path. | +| 104 | Resolve the root seeder with Container::build, matching Seeder::resolve and its fresh-instance convention. | Two programmatic db:seed runs receive distinct root objects; nested seeder remains fresh; container dependencies inject correctly. | +| 105 | In Option::parseGlob, truncate the non-wildcard prefix to the last slash before the first wildcard. Map app/Foo*.php to app and .env* to dot; preserve absolute/relative matching. | Wildcard after filename prefix, root dotfile glob, wildcard directly after slash, nested braces/classes, all watcher drivers receive an existing base. | +| 106 | Measure monotonic elapsed time from the previous scan's start and add exactly one second for filesystem timestamp granularity. Round GNU find's fractional-minute value up to its representable 0.01-minute unit; use ceiling whole minutes for non-GNU find. The existing mtime map deduplicates the intentional overlap. | Default interval plus a deliberately slow scan has no blind tail; sub-300ms interval never becomes -0.00; exact fractional rounding; non-GNU ceiling; no duplicate events. | +| 107 | Read server.settings.daemonize with a false default because application server settings may validly replace the framework settings map. | Minimal server.php with only worker_num; explicit true rejects; explicit false starts. | +| 108 | Check signalProcess's boolean return and report false as failure; retain Throwable handling for extensions/test doubles. | Native false return logs failure; true does not; throwing override logs; absent PID does nothing. | +| 109 | No change; see disposition above. | Retain full-content detection tests, including same-size/coarse-timestamp rewrites. | +| 110 | Enumerate hidden files in ScanFileDriver because WatchPath matching already accepts them and other drivers report them. | Hidden files and hidden directories under a watched target; matching exclusion pattern; parity with find/fswatch. | +| 111 | Prune modification-map entries whose recorded mtime is older than the current lookback/deduplication horizon. They can no longer suppress a future find result, so remove them without a file_exists syscall per historical path. | Repeated unique create/change/delete cycles keep the map bounded; no per-history stat calls; recreated path emits; overlap dedupe remains correct. | +| 112 | Add a manual-only `reverb:clear-state` command for crash recovery. Require all Reverb nodes using the selected Redis connection/prefix to be stopped, scan only RedisSharedState's `reverb:{*}:*` namespace across the selected connection's cluster nodes, and delete in bounded UNLINK batches (DEL fallback where unavailable). Provide `--dry-run`; otherwise require interactive confirmation or `--force`. Document the stop/clear/start runbook and explicitly exclude webhook buffer keys. Never schedule it, invoke it at boot, or wire it into automatic recovery; do not add leases, heartbeats, per-node aggregation, or hot-path Redis work without operational evidence. | Dry-run reports without deletion; confirmation/force behavior; only shared-state counters/locks/smoothing keys are removed; webhook and unrelated Redis data survive; multi-batch and cluster-wide scanning; stopped-nodes safety warning; command registration does not add scheduled/boot execution; docs runbook. | +| 113 | Render Wayfinder @see with docblock_method when explicitly supplied, otherwise original_method, never the allocated TypeScript identifier. | Reserved PHP method renamed in TS; collision suffix; invokable; named and controller files; IDE target string. | +| 114 | Strip the :parameters suffix from gathered middleware before class reflection for URL::defaults extraction. | Parameterized class middleware, alias-resolved middleware, unparameterized middleware, and absent class. | +| 115 | Detect duplicate route names before generation and fail with a generator exception listing both conflicting routes. Do not emit ambiguous overloads because Laravel route caching also treats duplicate names as invalid. | Two different URIs with one name; identical duplicate; error identifies methods/URIs; ordinary grouped namespaces compile. | +| 116 | After optional parameter replacement and trailing-slash normalization, floor an empty generated URL to slash before query-string concatenation. Avoid lookbehind-dependent JavaScript. | Root optional parameter omitted and present; root with query; nested optional route; generated runtime and typecheck. | +| 117 | Parse PHP integer literals by explicit prefix, including 0o/0O with octdec, and reuse the helper for signed literals. | 0o/0O, legacy octal, hex, binary, decimal, separators, positive/negative signs. | +| 118 | Remove the `command.tinker` binding and register TinkerCommand::class directly so AsCommand populates the lazy command map. Laravel's string key exists to support its deferred provider, which Hypervel intentionally omits; retaining an alias would carry that mechanism's residue into the canonical container surface. Record the deliberate omission at the port's natural source/test location and in the package's concise `Differences From Laravel` note. | Artisan list does not instantiate the command; `tinker` resolves lazily and runs; class resolution remains a worker singleton through Hypervel auto-singletoning; `command.tinker` is not bound; documentation identifies class resolution as the modern surface. | +| 119 | No change; see disposition above. | Preserve optional-list omission test. | +| 120 | Remove `App\Nova` from Tinker's shipped `dont_alias` default. Nova is a Laravel-only package with no Hypervel equivalent, so this is framework-specific integration residue rather than a canonical API. Preserve the configurable exclusion list for applications that need their own entries. | Shipped default is empty; published config is empty; an application-supplied `dont_alias` list still prevents aliases normally. | +| 121 | Replace the bound-closure appends read with Model::getAppends and drop the unused catch variable. | Empty and multiple appends; hidden/visible prefixes; accessor evaluation; exception probing unchanged. | +| 122 | Save and restore/delete COMPOSER_VENDOR_DIR in teardown with try/finally, and use ParallelTesting::tempDir with unconditional teardown cleanup for the coroutine scratch file. | Environment restored after success and failure; scratch removed after failed assertion; later TestStateRegistrars test sees original environment. | + +### Redis, cache, testing, facade documenter, prompts, Testbench, queue, auth, HTTP client, and Horizon + +| ID | Proposed implementation | Required tests | +|---:|---|---| +| 123 | No production change; retain the resolved disposition above. | Serializer-enabled get returns array, object, int, float, string, and null mapping without TypeError. | +| 124 | Pass the original Throwable as previous when wrapping Sentinel and Redis Cluster connection creation, and correct message punctuation. | Previous exception identity/type/trace for both paths; message; successful creation. | +| 125 | Widen RedisStore increment/decrement and their operation execute methods to bool\|int, matching Store. Pass false through without a TypeError. | Native false response; positive/negative integer result; repository/stack/tagged callers; exceptions still propagate. | +| 126 | Make TagMode::fromConfig throw InvalidArgumentException for any value other than all or any. Include the accepted values in the message. | Both valid modes; typo, case error, and empty value fail while resolving the store; benchmark command path. | +| 127 | Sort expected and actual policy-result maps by normalized model key before strict comparison. | Input models in reverse/random order vs unordered query; string and integer keys; real value mismatch still fails diagnostically. | +| 128 | Widen assertJsonValidationErrors and assertOnlyJsonValidationErrors to array\|string\|null and retain Laravel's clean “No validation errors were provided” assertion for null. The outer assertInvalid methods must never fail at PHP argument type checking. | JSON assertInvalid/assertOnlyInvalid with null yields an assertion diagnostic, not TypeError; explicit keys/messages; session behavior unchanged. | +| 129 | No change; see disposition above. | Preserve reset/subscriber lifecycle tests. | +| 130 | Read `view.compiled` untyped and use it only when `is_string($path) && $path !== ''`; otherwise return null and let required consumers' existing typed access remain the failure boundary. Remove ParallelTestingServiceProvider's explicit same-class singleton binding; Hypervel auto-singletons the unbound concrete class, so repeated class resolution retains the intended worker instance without copying Laravel's container implementation detail. | Null/missing/empty/non-string compiled path skips worker suffixing; valid string gets the suffix; the class is not explicitly bound before resolution and repeated class resolution returns the same auto-singleton instance. | +| 131 | Add per-run memoization for class imports by source file/namespace, parsed docblocks by exact string, facade method-name sets by class, and ReflectionMethodDecorator source ReflectionClass. Hoist immutable PHPDoc parser objects. Do not add production counters or test-only observability seams. | Generated output remains byte-identical; cache keys separate namespaces/files/docblocks and cannot cross-contaminate results; a focused before/after benchmark outside timing-sensitive CI demonstrates the repeated-input speedup. | +| 132 | Recursively walk traits-of-traits and parent traits when matching a method's source file for import resolution. Guard cycles/duplicates by trait name. | Public method declared in a nested trait resolves that trait's imports; parent trait chain; class import with same short name does not win. | +| 133 | Map supported PHPStan scalar refinements to runtime base types and preserve generic values: list/non-empty-list becomes array, non-empty-array preserves K,V, string refinements become string, signed integer refinements become int. Keep template resolution before unknown fallback. | Each listed pseudo-type; nested union/intersection/generic; template named like a class; generated facade PHPDoc contains no unintended mixed. | +| 134 | Catch only ReflectionException around getPrototype, reject unknown CLI flags with nonzero exit, send warnings/exceptions to STDERR, and add ext-tokenizer to facade-documenter's package requirements. | Malformed prototype docblock surfaces; typoed flag fails without writing; stdout remains generated output only; metadata assertion and standalone script smoke test. | +| 135 | Enforce the integer contract in NumberPrompt validation with a signed-decimal integer grammar and range checks before conversion. Accept signs and leading zeros; reject fractions, exponents, surrounding whitespace, and overflow rather than truncating/coercing them. Use the same parser for validation, returned value, min/max, step, and arrows. | Signed integers, zero, leading zeros, PHP int boundaries; fractions, exponents, whitespace, and overflow rejected; min/max, step, transform, and validation ordering. | +| 136 | After every arrow-key mutation, set cursorPosition to mb_strlen(typedValue) rather than adjusting by one. | Empty to multi-digit negative min/max; -1 to 0; 9 to 10; clamped step; subsequent typed character lands at the end. | +| 137 | Clamp the arrow padding passed to str_repeat to at least zero; DrawsBoxes will then size the box to the already terminal-bounded body without a second truncation pass. In cancel rendering, distinguish an empty string from the valid string 0 explicitly. | Wide terminal and long pasted value cannot throw or overflow the terminal bound; narrow terminal; cancel with 0 shows 0; empty cancel shows placeholder. | +| 138 | Replace newline parsing with one fixed length-prefixed binary frame for every logger message, including ordinary lines. Encode a compact type plus raw payload length and payload; parse with a cursor over the receive buffer and compact only consumed prefixes, avoiding repeated whole-buffer slicing. The producer and renderer are one internal deployment unit, so do not add protocol negotiation/version machinery. This preserves arbitrary bytes/newlines with less CPU and wire expansion than base64. Do not add production instrumentation for tests. | Multiline/blank/binary content for line, success, warning, error, label, sublabel, reset, partial, and commit; fragmented/coalesced socket reads; malformed length/type handling; exact bytes written for representative frames; in-process/process parity; a large-input benchmark outside timing-sensitive CI confirms linear scaling. | +| 139 | Send only each partial delta over IPC and feed it to one incremental partial-layout buffer shared by process and in-process loggers. Retain the existing visible log ring plus bounded unfinished wrapping/ANSI state, not the entire already-discarded prefix; split long words with the existing width semantics. This uses the Task's output limit rather than a new arbitrary cap. Clear on commit/stable reset. Transport, memory, and layout work must scale linearly with input. | Wire bytes and layout work grow linearly; long uncommitted streams stay bounded by the existing viewport and wrapping state; Unicode, ANSI, long words, multiline wrapping, commit/reset, and process/in-process final output parity. | +| 140 | Replace bool plus usleep animation ownership with a stop Channel and WaitGroup. The loop waits with the animation interval, stop wakes it immediately, and the caller joins the animation coroutine before erase/final render/terminal restore. Share the primitive between Spinner and Task. | Suspended in-flight render completes before final erase; callback success/failure; render failure; immediate completion; no extra frame after settlement; cursor restored. | +| 141 | Do not install Progress's pcntl exit handler inside a coroutine. Leave process signal ownership to the framework/runtime there; retain and restore the standalone non-coroutine handler. | Coroutine start leaves handler/async-signal state unchanged and cannot surface Swoole ExitException; standalone SIGINT handler setup/restore; manual and map settlement. | +| 142 | Track the state to return to when showing a transient revert error. The next key restores search rather than hardcoding active; ordinary validation errors still return to active. | CTRL_U in DataTable search, then typing/navigation remains search; CTRL_U in ordinary prompt; validation error recovery. | +| 143 | Add CTRL_P and CTRL_N to MultiSearchPrompt's up/down navigation arms. | Both bindings move highlight and do not clear the search match cache; boundary behavior matches sibling prompts. | +| 144 | Lazily cache only the expensive search-invariant natural column metrics on DataTablePrompt for the duration of one run; derive terminal-width fitting in O(columns) on each frame and clear the metrics when a new run starts. Keep public headers/rows mutable and provide explicit layout invalidation only for deliberate mutation during an active run, rather than narrowing the API or hashing all cells every keystroke. | Thousands-row prompt scans/sorts cells once across keystrokes and terminal resizes; resize refits without rescanning; mutation before or between runs recomputes automatically; in-run mutation plus explicit invalidation; no worker/static retention. | +| 145 | Fix all five valid subissues: eraseLines moves up one line per iteration; append NumberPrompt's transform to the number helper signature without reordering existing parameters; flushState forgets output and validation context keys; scrollbar last-character replacement is Unicode-aware; max error says at most. Do not expand FormBuilder. | Exact terminal escape sequence for counts 0/1/3; positional/named number calls and transform success/exception; context keys removed without whole-context flush; multibyte trailing character; max boundary and wording. | +| 146 | Use array_key_exists for nullable namespace/core-binding caches. Detect the actual workbench directory relative to package_path so root-monorepo mappings such as src/testbench/workbench/app and standalone workbench/app both match composer PSR-4 entries. | Negative lookup reads composer.json once; custom monorepo namespace detected; standalone layout; force refresh; nullable core binding cached. | +| 147 | Check base_path(app/Models/User.php) for the skeleton fallback. | Workbench model precedence; skeleton App\Models\User; no model; AUTH_MODEL override. | +| 148 | Move TerminatingConsole::flush from Symfony command configure/constructor into the beginning of handle. | Artisan list/help/completion construction preserves cleanup callbacks; executing sync intentionally flushes; custom persistent skeleton cleanup. | +| 149 | Import Testbench's existing LoadEnvironmentVariables subclass in CreatesApplication so TestCase's environment path gets the bundled .env.testbench fallback. Keep Testbench Foundation\\Application's explicit Foundation loader: its separately configured application resolver intentionally loads only the selected application's environment before overlaying its env array. | TestCase skeleton with/without .env uses the correct loader/fallback; custom environment file; Foundation Application retains its base-loader-plus-array sequence. | +| 150 | Resolve the foundation config directory from ReflectionClass(Hypervel\Foundation\Application)::getFileName rather than package_path's monorepo layout. Validate that the directory exists before use. | Split installed package layout; components monorepo root package; missing/corrupt install fails clearly; attribute loads framework config. | +| 151 | Always load the Testbench YAML/config state and Swoole-testing flag, then if BASE_PATH is already defined skip only source resolution, stale purge, runtime copy, and duplicate shutdown registration. Use BASE_PATH as the idempotence/ownership guard rather than a second static flag. | Two direct calls copy/register once while required config state is available; helper-before-TestCase; bin and ParallelRunner paths; pre-defined BASE_PATH; runtime clone is not overlaid. | +| 152 | Reuse the existing TEST_TOKEN sanitization grammar in runtime-copy paths; filter integer/numeric environment-map keys that cannot be represented by the declared subprocess string-key map; remove the unreachable migration-directory guard; null-coalesce teardown's migration cache; and remove duplicate migration-option resolution. Do not add a first-caller descriptor/assertion or expand ConfigContract for unsupported bootstrap implementations. | Traversal/punctuation/nonnumeric TEST_TOKEN; numeric env keys are omitted without TypeError and valid env survives; migration resolution only when enabled; teardown after failed setup preserves the original failure; one option resolution; supported bootstrap paths remain unchanged. | +| 153 | No change; see disposition above. | Preserve immediate-kill, non-drain, timeout event, and idempotency documentation coverage. | +| 154 | Remove only the dead SQL Server lock branch and document ARGV[2] as the Redis migration batch limit. Keep coroutine-hooked usleep and the shutdown-only 1ms poll; do not add Support\\Sleep or a Concurrent completion primitive. | Database grammar set excludes sqlsrv; Lua argument documentation/review; existing Worker sleep override and shutdown drain tests remain green. | +| 155 | Make DatabaseUserProvider and DatabaseTokenRepository accept ConnectionInterface\|ConnectionResolverInterface in the existing first constructor position and add the optional connection name only after the existing parameters. Framework factories pass the resolver/name and each operation resolves the current execution's connection; direct Laravel-style construction with a ConnectionInterface remains valid for non-pooled use and test doubles. Add concise constructor PHPDoc warning that a directly supplied connection must not outlive its execution. `getConnection()` returns the current resolved ConnectionInterface. No porting-guide entry is needed because the Laravel constructor form remains valid. | Existing Laravel-compatible positional construction and named arguments/mocks remain valid; framework construction does not borrow; provider/broker survives creator coroutine teardown; two concurrent requests use distinct leases; transaction state does not cross; configured and scoped-default connection selection; every repository/provider method; constructor docs name the direct-connection lifetime constraint. | +| 156 | Type ResponseSequence::$emptyResponse as Closure\|PromiseInterface\|null and initialize it to null. | whenEmpty closure receives no assignment TypeError and returns per call; promise path; default failure and dontFailWhenEmpty. | +| 157 | Initialize PendingRequest::$promise to null. | getPromise before send returns null; async send assigns promise; clone/new request does not inherit an uninitialized state. | +| 158 | Retain requestsReusableClient and getReusableClient for Laravel subclass compatibility. Make Response::cookies return nullable CookieJar to match its property and Laravel's actual null behavior for unpopulated/recorded responses. | Recorded/assertSent response cookies returns null; normal populated response returns jar; protected method reflection/subclass smoke test; async client behavior unchanged. | +| 159 | Use raw PHP_BINARY in HorizonRestartStrategy's array-form Symfony Process command. Keep PhpBinary::path only for shell command-string substitution. | Real array command starts a child executable; path containing spaces; environment argument; existing shell command strings remain correctly quoted; remove the mock-only blind spot. | +| 160 | Port the current laravel/vonage-notification-channel package into a Hypervel split package, then make Horizon's existing routeSmsNotificationsTo API effective by adding the missing route in SendNotification and channel selection in LongWaitDetected::via, adapted to the current `vonage` channel, `toVonage`, and VonageMessage. Do not copy Horizon upstream's obsolete `nexmo` name or introduce a second/deprecated driver. `Vonage\Client` caches service objects whose `APIResource` mutates `lastRequest`/`lastResponse` around yielding HTTP calls, so framework-created channels build a fresh SDK Client per send while sharing only normalized immutable configuration and Hypervel's coroutine-safe PSR-18 transport. Add per-execution memoization only later if measurement proves construction material. Keep the Vonage facade non-caching and preserve direct construction with a supplied Client and per-message `usingClient` overrides. Add the package README, canonical notification docs, Horizon docs, `HorizonServiceProvider.stub`, and Horizon Boost notification reference using `vonage` only. | Ported package provider/config, route resolution, message construction, channel send/failure, facade, direct constructor, and per-message override; deterministic concurrent sends cannot exchange SDK request/response state; repeated sends get distinct SDK clients but reuse the safe HTTP transport; Horizon's two consumers use `vonage`; absent number adds no channel; mail/Slack composition; public routeSmsNotificationsTo remains unchanged; all docs/stubs/Boost references contain no `nexmo` surface. | + +## Commit and dependency structure + +Use package-sized commits that remain reviewable and bisectable. The following order avoids building fixes on obsolete primitives: + +1. Worker-default and execution-state primitives: 25, 33, 49, 103. +2. Pool/resource ownership: 10-12, 22, 27-29, 81, 155. +3. Transaction/cache consistency: 43-47 and 57, using their package-specific lock and atomic-publication designs. +4. Nested-set authoritative mutation preparation and relation guards: 78-80. +5. Validation and data representation: 15-21, 30-35, 82, 94-98. +6. Search and request pipelines: 36-42, 62-77, 99-102. +7. Observability: 48-55. +8. Reverb recovery command and runbook: 112, with no runtime state-model change. +9. Queue cleanup: only the two valid parts of 154; 153 deliberately stays unchanged. +10. Vonage notification channel port followed by Horizon wiring: 160. +11. Remaining package-local correctness work by package, followed by performance/docs/cleanup. + +Do not combine unrelated packages merely because their findings have the same severity. + +## Verification protocol + +For every changed test file: + +1. From the components repository root, run that exact test file immediately with `./vendor/bin/phpunit --no-progress path/to/Test.php`. +2. For deterministic concurrency tests, run the exact file repeatedly and under the parallel runner where supported. +3. Run the complete affected package test directory after its individual files pass. +4. Run integration suites for every affected external system: MySQL/MariaDB and PostgreSQL for validation/permission/database semantics; Redis for Sanctum/Reverb/cache; filesystem cloud adapter tests where credentials/fixtures are provided. + +Additional required checks: + +- Wayfinder: run npm test, npm run test:cached, and npm run typecheck from src/wayfinder. +- Split-package metadata changes: run the package metadata tests and a clean standalone Composer install/autoload smoke test. +- Facade documenter: run lint and write modes against representative facades, confirm diagnostics use STDERR, then run static analysis. +- Documentation changes: verify every claimed API and difference against the final source; update package README and src/docs together where both describe it. +- Runtime races: use barriers/channels to force the bad ordering. A test that merely starts two coroutines without controlling their interleaving is insufficient. + +After all package work is complete: + +1. Run `composer fix` once as the repository checkpoint. It owns formatting, static analysis, the parallel suite, Testbench package tests, and dogfood tests; do not duplicate those full checks immediately beforehand. +2. Inspect git diff and git status; ensure generated fixtures, temporary files, environment changes, node output, and unrelated user changes are absent. + +## Completion criteria + +- Every audit ID has the disposition recorded above. +- Findings 4, 9, 14, 93, 109, 119, 129, and 153 remain unchanged for the stated reasons. +- Finding 88 is closed by 32 rather than implemented twice. +- Finding 123 remains fixed and regression-covered. +- All 150 open unique remediation entries have production, test, documentation, or metadata changes as specified. +- No worker-global mutable state is introduced without an explicit boot-only contract and reset path. +- No pooled borrowed resource escapes its operation scope. +- No cache fill can republish state after a completed revocation/invalidation. +- All exact-file, package, integration, split-package, TypeScript, static-analysis, lint, dogfood, and final composer fix checks pass. From 9c69065703ee07381bc455895eb62fe264403ea4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:57:07 +0000 Subject: [PATCH 02/22] docs(validation): add audit remediation plan Record the signed-off validation remediation design for audit findings 15 through 20 and the additional defects found at their shared boundaries. Preserve the correctness, performance, Laravel-compatibility, database-matrix, and long-lived-worker constraints that implementation must satisfy. --- ...validation-audit-remediation-plan-codex.md | 572 ++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 docs/plans/2026-08-22-2137-components-validation-audit-remediation-plan-codex.md 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 000000000..29658f15d --- /dev/null +++ b/docs/plans/2026-08-22-2137-components-validation-audit-remediation-plan-codex.md @@ -0,0 +1,572 @@ +# Validation audit remediation plan + +Status: Signed off by `claude-fixes` on 2026-08-22; ready for implementation + +Branch: `audit/validation-remediation` from `0.4` at `7741eaad0435450e500304f173dde4f4a5488646` + +Scope: master-audit findings 15–20 plus three validation defects exposed while reviewing their shared execution and compilation 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, single compiled execution loop, inline predicates, exclusion prepass, and wildcard database-presence batching. + +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 maintenance registry of Laravel rule names. Execution-local facts may live only on the validator-owned verifier that is 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 — closure-backed exclusion rules are parsed twice:** `RuleCompiler::compile()` parses every rule during its context scan and again during compilation, so `ExcludeIf` / `ExcludeUnless` conditions run twice and the first result is discarded. + +Out of scope: unrelated validation behavior, new validation APIs, removing pipe-delimited rules, or reverting the compiled validator. + +## 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 one real validation loop; delegated rules still call 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 tests are green: 264 tests / 604 assertions across the six optimizer/compiler test files, and 18 tests / 38 assertions in the existing database batching integration file. 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 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 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 each rule once during ordinary execution. Hypervel's second parse is introduced solely by the compiled plan's context pre-scan and is unnecessary. + +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. +- Optimizer disqualification follows the ability to mutate this `Validator`'s data, not a wrapper interface that never reaches the wrapped rule. +- The precomputed verifier stores only facts a database query proved. Anything unqueried or ambiguous delegates. +- All verifier facts and fallback memoization live only for the current `passes()` execution. No `CoroutineContext`, static map, or worker cache is permitted. + +## Implementation plan + +### 1. 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. + +### 2. Add a conservative inline-preflight boundary + +Files: + +- `src/validation/src/PlanExecutor.php` +- `src/validation/src/Concerns/ValidatesAttributes.php` +- `src/validation/src/Enums/CheckType.php` +- `tests/Validation/ValidationPlanExecutorTest.php` +- `tests/Validation/ValidationValidatorTest.php` + +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: + +```php +if (! is_scalar($value) && ! $value instanceof Stringable) { + return false; +} +``` + +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 will not reach the user-configurable exponent guard: + +```php +! ( + $check->param['mode'] === SizeMode::Numeric + && is_numeric($value) + && Str::contains((string) $value, 'e', ignoreCase: true) +) +``` + +Objects are already rejected, so this 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. + +Add an optional fourth step to `CheckType`'s existing maintenance docblock: an inline case may be added to the preflight allowlist only after proving repeat evaluation is free of user callbacks, I/O, warnings, and reachable exceptions; omission is safe and only disables batching across that prefix. Do not add behavior or a second registry to the enum. + +Tests: + +- assert every allowed and disallowed case, the object/resource guard, array support, and the size exponent exception; +- prove a Stringable object is not cast during preflight; +- 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. + +### 3. Build presence candidates from active compiled plans in order + +Files: + +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` +- validation database integration tests described in step 9 + +Rewrite the candidate half of `maybeBatchDatabaseChecks()` around the already filtered `compiledPlans`, not raw `$rules`: + +1. Retain the current wildcard-only optimization boundary. +2. Skip plans whose `sometimes` flag is set when the concrete key is absent. +3. Locate each `Exists` / `Unique` `DelegatedCheck` in the concrete plan and extract metadata from its `originalRule`, preserving string, array, and rule-object forms. +4. Apply the shared non-implicit and invalid-upload predicates to the current value. +5. Walk only the checks preceding that presence check, in declaration order: + - 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. +6. Preserve an active uncertain query shape for collision detection, but add no value for it. If all candidates are uncertain, no batch query or verifier swap occurs. + +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 -> retain active shape, 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 mode is compiled from the sibling `integer`, but value-first size dispatch treats `'abc'` as length 3, so `min` passes and `integer` fails. Stopping at the 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. Continue using the existing full-query-shape key and conservative table/column collision guard; a possibly executable uncertain shape must still prevent another shape on the same table/column from intercepting its runtime probe. + +`stopOnFirstFailure` can still make an already-issued batch query unnecessary when an earlier attribute later fails. This is an existing consequence of pre-execution batching and does not justify stateful or phased machinery. + +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 preceding custom/delegated rule makes only that concrete value uncertain; +- an uncertain prefix that later fails performs no fallback; one that reaches presence performs one fallback; +- an all-uncertain group performs no batch query; +- two attributes sharing one cached `AttributePlan` can make different candidate decisions without cross-request/attribute state; +- string, array-tuple, `Exists`, and `Unique` object forms retain their metadata and messages. + +### 4. Track the real optimizer mutation surface + +Files: + +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationCompiledExecutionTest.php` + +Correct `compiledPlansContainValidatorAwareRules()` by unwrapping `InvokableValidationRule`: + +```php +if ($check->ruleObject instanceof InvokableValidationRule) { + if ($check->ruleObject->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` cannot mutate this validator and must not globally disable exclusion or presence optimization. + +Keep `ClosureValidationRule` as a blocker because it passes the live validator as the fourth callback argument. Keep an actual inner `ValidatorAwareRule` as a blocker. Regardless of the global decision, a custom rule preceding presence remains locally uncertain under step 3. + +Tests: + +- an unrelated plain modern validation rule does not prevent wildcard batching; +- an inner `ValidatorAwareRule` still disables precomputation; +- a closure rule still disables precomputation; +- mutation before presence retains ordinary execution semantics. + +### 5. 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 9 + +#### 5.1 Query only normalizable candidates + +Normalize each concrete candidate independently. Strings, integers, floats, `Stringable` values, and one-dimensional arrays containing only those types remain supported. Booleans, null, and other unsupported candidates are skipped without declining safe siblings. Unsupported runtime probes must delegate before consulting any stored fact. + +Keep query binding semantics separate from lookup keys. Do not string-cast every submitted SQL value: retain the raw string, integer, or float query value so the connection performs the same driver-specific binding as the ordinary verifier; cast a supported `Stringable` once. Alongside it, build the same type-insensitive `(string)` lookup key that `PrecomputedPresenceVerifier` uses today. This prevents the batch path from changing a typed PostgreSQL probe merely to deduplicate it without assuming that PDO will return the same PHP type it received. + +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 that string key and retain the first raw value as the representative SQL binding. Use the identical key function for submitted candidates, runtime probes, and every value fetched by both query stages before comparing results or populating `exactHits`, `knownPresent`, and `provenAbsent`. Integer/float candidates may be returned as strings because of the column type, driver, or PDO options; equal string keys must remain fast-path hits rather than becoming ambiguous misses. Booleans are excluded before normalization, so `false` cannot collide with an empty string. Keep the representation as plain arrays/maps rather than introducing a value object. + +Do not partially submit an array containing an unsupported nested item. Its eventual `getMultiCount()` must remain one coherent fallback. + +#### 5.2 Use two grouped stages + +For every collision-free query shape: + +1. Query all distinct submitted representative values in chunks of 1,000. +2. Normalize every fetched value through the shared string-key function, then build the exact-key hit map and submitted-key misses. +3. If stage 1 fetched nothing, every submitted value is proven absent. +4. If the whole group has one distinct submitted value and stage 1 returned a nonexact representation, that sole value is already known present for scalar presence semantics; no isolation query is needed. +5. 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. +6. If stage 2 fetched nothing, every submitted miss is proven absent. +7. Every miss returned with the same normalized key 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. +8. If stage 2 is non-empty and there was exactly one distinct miss, that isolated miss is known present even when the stored representation differs. +9. Other misses from a non-empty multi-miss stage remain unresolved and each runtime scalar probe delegates, memoized per normalized value 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 lookup: + +```text +exactHits stage 1 returned this exact normalized string key +knownPresent a single-input query or stage 2 proved a scalar miss is present +provenAbsent a stage returned no row for this submitted value +``` + +Anything in no map was unqueried or ambiguous and delegates. Cache only actual fallback scalar counts inside this verifier instance. 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. + +#### 5.3 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. After normalizing and deduplicating the requested array, `getMultiCount()` follows these rules: + +- any unknown value delegates the whole multi-count; +- a `knownPresent` value is usable only when it is the array's sole distinct input; 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; +- 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; +- fallback count memoization is execution-local and keyed by distinct normalized scalar probe; +- 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 integer candidates against text columns and numeric/decimal column results; +- 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. + +### 6. Restore exclusion order and wildcard authority + +Files: + +- `src/validation/src/Validator.php` +- `tests/Validation/ValidationPreEvaluatedExclusionsTest.php` + +Drive `preEvaluateExclusions()` from `compiledPlans`. Only pre-evaluate when `checks[0]` is a delegated `ExcludeIf` or `ExcludeUnless`. `nullable`, `bail`, and `sometimes` are plan flags rather than executable checks, so they do not occupy position zero. + +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); +} +``` + +Retain the existing safety skips for boolean/null-dependent coercion and non-scalar condition values. Delete `parseExcludeRule()` and the numeric-segment regex `resolveWildcardConditionField()` once they have no caller. + +Tests: + +- `integer|exclude_if:foo,bar` retains the integer error even though the attribute is later excluded; +- exclusion-first still removes data before execution; +- `bail`, `nullable`, and `sometimes` flags before exclusion do not prevent the fast path; +- literal numeric segment case: `data.5.items.0.value` resolves `data.5.items.*.type` with capture `0`, not literal segment `5`; +- one/multiple wildcard captures, mismatched counts, nested arrays, and escaped-dot field names match normal dependent-rule execution; +- parent pre-exclusion still suppresses descendants. + +### 7. Reset transient numeric state, parse once, and remove dead plan metadata + +Files: + +- `src/validation/src/PlanExecutor.php` +- `src/validation/src/AttributePlan.php` +- `src/validation/src/RuleCompiler.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 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. Do not cache object parse results beyond this compile: `ExcludeIf` and `ExcludeUnless` deliberately evaluate their closures while stringifying, so a worker-global object cache would be incorrect. This removes duplicate parsing work and ensures a closure-backed exclusion condition is evaluated once per compile, with the result that is actually compiled. + +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 pair-consuming path, keep `collectContext()`'s `is_string($parsedName)` guard, and consume the parsed name/parameters only for the remaining string, array, and `Stringable` forms, including `Exists` / `Unique`. + +`compileAllDelegated()` does not need parsed context after the dead size-mode metadata is removed. Let `compileRuleDelegated()` continue parsing each rule once as it emits the delegated check; do not add a second generalized compilation abstraction solely to share a short control flow. + +Then remove from `AttributePlan`: + +- `$required`; +- `$hasImplicitRule`; +- `$sizeMode` and its `SizeMode` import. + +Remove every compiler write and delete `RuleCompiler::isImplicitRule()` with its duplicate list. Keep size mode only in `compile()`'s local context and in the `InlineCheck` parameters that consume it. `compileAllDelegated()` no longer needs the 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. + +Replace compiler 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 mode come from the emitted inline check; +- closure-backed `ExcludeIf` and `ExcludeUnless` conditions are invoked exactly once per `compile()` call, and their single result determines the emitted check; +- all-delegated subclass plans retain correct ordered behavior without stored context. + +### 8. Fix strict `date_format` round trips + +Files: + +- `src/validation/src/Concerns/ValidatesAttributes.php` +- `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. + +### 9. 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. + +## 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/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 + +vendor/bin/phpunit tests/Validation + +bin/run-database-tests.sh sqlite --filter=ValidationBatchDatabaseCheckerTest +bin/run-database-tests.sh mysql --filter=ValidationBatchDatabaseCheckerTest +bin/run-database-tests.sh mariadb --filter=ValidationBatchDatabaseCheckerTest +bin/run-database-tests.sh pgsql --filter=ValidationBatchDatabaseCheckerTest +``` + +Run the existing benchmark before source changes and after the final implementation, using multiple runs rather than one noisy sample: + +```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 the flat and simple cases affected by the inline numeric-state reset. 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 + +- [ ] Laravel rule syntax, public APIs, rule/message order, and extension points remain compatible except for the verified upstream `date_format` and resource-valued `json` bug fixes. +- [ ] Pipe-delimited and array rule forms compile to the same correct behavior. +- [ ] O(n) wildcard expansion, immutable worker-lifetime plan caching, and the single execution loop remain intact. +- [ ] Common `required|integer|exists`, `email|exists`, `date|exists`, and `required|array|exists` wildcard shapes remain batched. +- [ ] No invalid value is submitted merely because preflight could not prove its prefix; uncertain probes fall back only if execution reaches presence. +- [ ] Boolean presence candidates use the ordinary verifier path; driver-specific binding is never approximated by the batch optimizer. +- [ ] SQL bindings retain their raw supported types while candidates, runtime probes, and fetched values share one PDO-type-insensitive string lookup key. +- [ ] Pre-excluded, absent-sometimes, empty, nullable, and proven-failing values issue no presence query. +- [ ] Case-insensitive/collation-equivalent `unique` values cannot false-pass. +- [ ] Array-valued `exists` agrees with database `DISTINCT` semantics, including chunk boundaries. +- [ ] No optimizer result is stored in a shared plan, static property, or coroutine context. +- [ ] Exclusion pre-evaluation preserves earlier failures and resolves wildcard captures through the established authority. +- [ ] Inline messages cannot inherit transient numeric state. +- [ ] Resource-valued JSON fails cleanly in inline and delegated execution, with no duplicate JSON predicate. +- [ ] Closure-backed exclusion rules are parsed/evaluated once per compile. +- [ ] Dead plan fields, compiler writes, duplicate implicit-rule knowledge, and obsolete helpers/tests/comments are removed. +- [ ] Every retained source comment and docblock describes the final design; no superseded optimizer explanation remains. +- [ ] The validation database suite runs through the existing MySQL, MariaDB, PostgreSQL, and SQLite workflow discovery. +- [ ] Focused, full validation, database-matrix, benchmark, static-analysis, formatting, and final repository checks pass. From 29bd93e7a878d924db8a3e9e06dcadad9642ac50 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:40:54 +0000 Subject: [PATCH 03/22] fix(validation): return distinct presence values DatabasePresenceVerifier::getMultiCount() counts distinct stored values, but the batching fetch path previously returned every matching row. Select distinct column values at the shared verifier boundary so precomputed array-presence facts use the same database semantics as ordinary validation. Update the focused verifier test to require the distinct query while retaining connection, exclusion, condition, and result assertions. --- src/validation/src/DatabasePresenceVerifier.php | 4 ++-- tests/Validation/ValidationDatabasePresenceVerifierTest.php | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/validation/src/DatabasePresenceVerifier.php b/src/validation/src/DatabasePresenceVerifier.php index 0ddd44cb7..fdecf20f4 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/tests/Validation/ValidationDatabasePresenceVerifierTest.php b/tests/Validation/ValidationDatabasePresenceVerifierTest.php index a4583e601..8d406dfd2 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( From ac6062c79a301a1f5427531cc06bde70970013df Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:41:01 +0000 Subject: [PATCH 04/22] refactor(validation): make benchmarks deterministic Keep benchmark configuration local to each command invocation, validate scenario and iteration options explicitly, and replace random workload construction with deterministic values. Warm each execution path independently, require optimized and legacy results to agree before timing, and report a true median over measured iterations. Remove benchmark-only static cleanup from the global PHPUnit subscriber and cover valid, unknown, zero-iteration, and non-integer command inputs through the public console surface. Add fluent-rule and typeless-size scenarios so the benchmark exercises the optimized forms introduced by this remediation. --- .../src/PHPUnit/AfterEachTestSubscriber.php | 1 - .../Console/BenchmarkValidationCommand.php | 139 ++++++++++++++---- .../BenchmarkValidationCommandTest.php | 74 ++++++++++ 3 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 tests/Validation/BenchmarkValidationCommandTest.php diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index dabb68c32..3e30770c6 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/Console/BenchmarkValidationCommand.php b/src/validation/src/Console/BenchmarkValidationCommand.php index eafd3c956..6f0223cc8 100644 --- a/src/validation/src/Console/BenchmarkValidationCommand.php +++ b/src/validation/src/Console/BenchmarkValidationCommand.php @@ -9,10 +9,12 @@ use Hypervel\Support\Arr; use Hypervel\Support\MessageBag; use Hypervel\Support\Str; +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,29 +43,41 @@ 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)); + + 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); - self::$iterations = max(1, (int) $this->option('iterations')); + if ($iterations === false || $iterations < 1) { + $this->error("Invalid iterations: {$iterationsOption}. Must be a positive integer."); + + return self::FAILURE; + } $this->components->info('Hypervel Validation Benchmark'); @@ -71,20 +85,36 @@ public function handle(): int $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 = (new Validator($translator, $data, $rules))->passes(); + $optimizedMs = $this->benchmark( + fn () => (new Validator($translator, $data, $rules))->passes(), + $iterations, + ); RulePlanCache::flushState(); - $legacyMs = $this->benchmark(fn () => (new LegacyValidator($translator, $data, $rules))->passes()); + ValidationRuleParser::flushState(); + $legacyPassed = (new LegacyValidator($translator, $data, $rules))->passes(); - $speedup = $legacyMs > 0 ? round($legacyMs / $optimizedMs, 1) : 0; + if ($optimizedPassed !== $legacyPassed) { + $this->error("Optimized and legacy validation disagree for scenario: {$scenario}."); + + return self::FAILURE; + } + + $legacyMs = $this->benchmark( + fn () => (new LegacyValidator($translator, $data, $rules))->passes(), + $iterations, + ); + + $speedup = round($legacyMs / $optimizedMs, 1); $results[] = [ $scenario, @@ -101,28 +131,32 @@ 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]; } /** @@ -137,7 +171,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 +187,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 +220,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 +271,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/tests/Validation/BenchmarkValidationCommandTest.php b/tests/Validation/BenchmarkValidationCommandTest.php new file mode 100644 index 000000000..f2d00d093 --- /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()]; + } +} From d3b14d28bc68b15c0e9c06029dda4e4b51d32376 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:41:15 +0000 Subject: [PATCH 05/22] refactor(validation): preserve semantics in compiled execution Restore Laravel's generic Stringable rule canonicalization, delete the Hypervel-only presence-rule metadata layer, and compile rule tokens once into immutable plans. Replace the four-way size mode with the validator's canonical numeric-rule authority and one value-dispatched size implementation shared by inline and delegated checks. Make exclusion pre-evaluation and presence planning follow declared rule order. Preflight only reviewed side-effect-free predicates, keep mutation and global early-stop gates at their actual boundaries, resolve dependent wildcards through the validator's existing authority, and delegate every uncertain probe to ordinary validation. Key execution-local presence facts by complete query shape and PDO binding identity. Preserve raw binding types, use database-backed two-stage isolation for collation and coercion matches, retain DISTINCT array semantics, memoize only proven fallback counts for one passes() call, and keep all shared cached plans free of request state. Also fix falsey fluent database-rule serialization, resource-valued JSON validation, strict date-format round trips, escaped-attribute stop checks, and transient numeric message state while removing the superseded helpers, fields, contracts, and enum. --- src/validation/src/AttributePlan.php | 16 +- src/validation/src/BatchDatabaseChecker.php | 220 ++++--- .../src/Concerns/ValidatesAttributes.php | 61 +- .../src/Contracts/DatabasePresenceRule.php | 25 - src/validation/src/DelegatedCheck.php | 4 - src/validation/src/Enums/CheckType.php | 3 + src/validation/src/Enums/SizeMode.php | 18 - src/validation/src/PlanExecutor.php | 252 +++++--- .../src/PrecomputedPresenceVerifier.php | 219 +++++-- src/validation/src/RuleCompiler.php | 221 ++----- src/validation/src/Rules/DatabaseRule.php | 21 +- src/validation/src/Rules/Exists.php | 3 +- src/validation/src/Rules/Unique.php | 22 +- src/validation/src/ValidationRuleParser.php | 2 +- src/validation/src/Validator.php | 561 ++++++++---------- 15 files changed, 794 insertions(+), 854 deletions(-) delete mode 100644 src/validation/src/Contracts/DatabasePresenceRule.php delete mode 100644 src/validation/src/Enums/SizeMode.php diff --git a/src/validation/src/AttributePlan.php b/src/validation/src/AttributePlan.php index 45dade3c8..2d1996f95 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 d2cd0cbf8..2d0834f7a 100644 --- a/src/validation/src/BatchDatabaseChecker.php +++ b/src/validation/src/BatchDatabaseChecker.php @@ -7,126 +7,170 @@ use Stringable; /** - * Execute batched database queries for wildcard exists/unique validation. + * Query wildcard database-presence candidates in groups. * - * 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. - * - * 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. + * Query and register database-proven facts for one query shape. * - * 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. - * - * @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; + } + } - $verifier->addLookup($meta['table'], $meta['column'], $fetched); + $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; + } + } + } + + 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. - * - * Replays scalar where conditions matching DatabasePresenceVerifier::addWhere() - * behavior. Uses write PDO to match the presence verifier's behavior. + * Run chunked queries for one database query shape. * - * @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 +178,37 @@ private static function queryValues( } /** - * Deduplicate and cast values to strings for batch queries. + * Normalize candidates while retaining the first raw SQL binding. * - * @param array $values - * @return null|list + * An unsupported array item rejects only that concrete array candidate; + * safe siblings in the same query-shape group remain batchable. + * + * @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; + $rawValue = $item instanceof Stringable ? substr($bindingKey, 1) : $item; + $candidateValues[$bindingKey] ??= $rawValue; + } + + 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 9e2ff5a70..96d7ec478 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)) { diff --git a/src/validation/src/Contracts/DatabasePresenceRule.php b/src/validation/src/Contracts/DatabasePresenceRule.php deleted file mode 100644 index abc19a43b..000000000 --- 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/DelegatedCheck.php b/src/validation/src/DelegatedCheck.php index c65c0fe63..48fc3a525 100644 --- a/src/validation/src/DelegatedCheck.php +++ b/src/validation/src/DelegatedCheck.php @@ -16,9 +16,6 @@ * @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,7 +23,6 @@ public function __construct( public string $ruleName, public array $parameters, - public ?object $ruleObject = null, public mixed $originalRule = null, ) { } diff --git a/src/validation/src/Enums/CheckType.php b/src/validation/src/Enums/CheckType.php index 9b64e78f9..8edda0ef9 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 80a85c23f..000000000 --- a/src/validation/src/Enums/SizeMode.php +++ /dev/null @@ -1,18 +0,0 @@ - $plan) { $attribute = (string) $attribute; - - if (isset($this->preExcludedAttributes[$attribute])) { - $this->excludeAttribute($attribute); - continue; - } + $cleanedAttribute = $this->replacePlaceholderInString($attribute); if ($this->stopOnFirstFailure && $this->messages->isNotEmpty()) { break; @@ -87,22 +81,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 +107,100 @@ 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)) { - break; + if (isset($this->failedRules[$cleanedAttribute])) { + $failedRuleNames = array_keys($this->failedRules[$cleanedAttribute]); + + if (in_array('uploaded', $failedRuleNames, true) + || array_intersect($failedRuleNames, $this->implicitRules) + ) { + 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)) + || (! Str::contains((string) $value, 'e', ignoreCase: true) + && (! is_float($value) || is_finite($value))), + default => false, + }; + } + /** * Execute an inline check against a value. * @@ -158,7 +230,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 +249,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 +318,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 +354,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 1ecfcc258..e9968ca4b 100644 --- a/src/validation/src/PrecomputedPresenceVerifier.php +++ b/src/validation/src/PrecomputedPresenceVerifier.php @@ -4,123 +4,202 @@ namespace Hypervel\Validation; +use Closure; +use DateTimeInterface; use Stringable; /** - * 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 && ! $value instanceof Stringable) + ) { + 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); + } - if (! isset($this->lookups[$key])) { - return $this->fallback?->getCount($collection, $column, $value, $excludeId, $idColumn, $extra) ?? 0; + $bindingKey = self::bindingKey($value); + + if ($bindingKey === null) { + return $this->fallback->getCount($collection, $column, $value, $excludeId, $idColumn, $extra); } - $normalized = self::normalizeValue($value); + $lookup = $this->lookups[$lookupKey]; + + if (isset($lookup['exactHits'][$bindingKey]) || isset($lookup['knownPresent'][$bindingKey])) { + return 1; + } - if ($normalized === null) { - return $this->fallback?->getCount($collection, $column, $value, $excludeId, $idColumn, $extra) ?? 0; + if (isset($lookup['provenAbsent'][$bindingKey])) { + return 0; } - return isset($this->lookups[$key][$normalized]) ? 1 : 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 ($bindingKey === null) { + return $this->fallback->getMultiCount($collection, $column, $values, $extra); + } + + $bindingValues[$bindingKey] = substr($bindingKey, 1); + } - if ($normalized === null) { - return $this->fallback?->getMultiCount($collection, $column, $values, $extra) ?? 0; + $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($seen[$normalized]) && isset($lookup[$normalized])) { - ++$count; - $seen[$normalized] = true; + 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 +211,33 @@ 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. */ - private static function normalizeValue(mixed $value): ?string + public static function bindingKey(mixed $value): ?string { - if (! is_scalar($value) && ! $value instanceof Stringable) { + $normalizedValue = self::normalizeValue($value); + + return $normalizedValue === null + ? null + : (is_int($value) ? 'i' : 's') . $normalizedValue; + } + + /** + * Normalize a supported presence value for lookup comparisons. + */ + public static function normalizeValue(mixed $value): ?string + { + // Connection::prepareBindings() formats dates through the query grammar. + if ($value instanceof DateTimeInterface) { + return null; + } + + if ((! is_string($value) && ! is_int($value) && ! is_float($value)) + && ! $value instanceof Stringable + ) { return null; } diff --git a/src/validation/src/RuleCompiler.php b/src/validation/src/RuleCompiler.php index 1207d9e64..db4ed21dd 100644 --- a/src/validation/src/RuleCompiler.php +++ b/src/validation/src/RuleCompiler.php @@ -4,10 +4,8 @@ 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; /** @@ -15,8 +13,8 @@ * * 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 +24,19 @@ 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($parsedRules, $numericRules); - $context = self::collectContext($rules); - $plan->sizeMode = $context['sizeMode']; - - foreach ($rules as $rule) { - self::compileRule($rule, $plan, $context); + foreach ($rules as $index => $rule) { + self::compileRule($rule, $parsedRules[$index], $plan, $context); } return $plan; @@ -45,8 +46,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. + * Retains the same meta-flag resolution so the execution loop's + * attribute-level logic still works. * * @param list $rules As produced by ValidationRuleParser::explode() */ @@ -54,9 +55,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,28 +65,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; + $format = $parsedParameters[0] ?? null; if (is_scalar($format) || $format instanceof Stringable) { $dateFormat = (string) $format; @@ -100,94 +97,40 @@ 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. if ($ruleName === 'Nullable') { @@ -209,10 +152,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, @@ -230,37 +169,9 @@ private static function compileRule(mixed $rule, AttributePlan $plan, array $con private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): 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; @@ -268,14 +179,10 @@ private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): [$ruleName, $parameters] = 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; @@ -289,10 +196,6 @@ private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): return; } - if (self::isImplicitRule($ruleName)) { - $plan->hasImplicitRule = true; - } - $plan->checks[] = new DelegatedCheck( ruleName: $ruleName, parameters: $parameters, @@ -305,7 +208,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 { @@ -418,20 +321,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 +342,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 +401,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 +419,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 2dda68071..473d83af5 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 182e885fd..f37f41753 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 4e3689762..3f897fb67 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 eff41e5e2..602e037ae 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 03451cb8b..d876759a1 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 { @@ -110,6 +112,13 @@ class Validator implements ValidatorContract */ protected array $compiledPlans = []; + /** + * Parsed presence-rule tables for the current passes() invocation. + * + * @var array + */ + private array $parsedTables = []; + /** * The original presence verifier, saved during batched DB checks. * @@ -437,18 +446,14 @@ public function passes(): bool [$this->distinctValues, $this->failedRules, $this->excludeAttributes] = [[], [], []]; $this->originalPresenceVerifier = null; $this->preExcludedAttributes = []; + $this->parsedTables = []; $this->compiledPlans = $this->compileRules(); - // 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 (static::class === self::class) { + $unresolvedExclusionAttributes = $this->preEvaluateExclusions( + ! $this->compiledPlansUseDataMutatingRules(), + ); if ($this->preExcludedAttributes !== []) { $this->compiledPlans = array_filter( @@ -457,14 +462,16 @@ public function passes(): bool ARRAY_FILTER_USE_KEY, ); } - } - $activeVerifier = $this->presenceVerifier; - if ($canOptimize - && $activeVerifier !== null - && $activeVerifier::class === DatabasePresenceVerifier::class - ) { - $this->maybeBatchDatabaseChecks($activeVerifier); + $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, $unresolvedExclusionAttributes); + } } try { @@ -520,7 +527,7 @@ protected function compileRules(): array } $plan = $isBaseValidator - ? RuleCompiler::compile($rules) + ? RuleCompiler::compile($rules, $this->defaultNumericRules) : RuleCompiler::compileAllDelegated($rules); if ($isBaseValidator) { @@ -534,18 +541,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 +586,107 @@ 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. + * This pass records resolved exclusions on the validator instance. + * + * @return array attributes whose exclusion outcome remains unresolved + * @phpstan-impure */ - protected function preEvaluateExclusions(): void + protected function preEvaluateExclusions(bool $canPreEvaluate): array { - /** @var array $cache */ - $cache = []; + $unresolvedAttributes = []; + /** @var array $exclusionOutcomes */ + $exclusionOutcomes = []; - foreach ($this->rules as $attribute => $attributeRules) { + foreach ($this->compiledPlans as $attribute => $plan) { $attribute = (string) $attribute; + $firstExclusionIndex = null; + $hasLaterExclusion = false; - if (! is_array($attributeRules)) { - continue; - } + foreach ($plan->checks as $index => $check) { + if ($check instanceof DelegatedCheck && in_array($check->ruleName, $this->excludeRules, true)) { + $firstExclusionIndex ??= $index; - foreach ($attributeRules as $rule) { - $parsed = $this->parseExcludeRule($rule); - if ($parsed === null) { - continue; + if ($index !== 0) { + $hasLaterExclusion = true; + } } + } - [$action, $conditionField, $allowedValues] = $parsed; + if ($firstExclusionIndex === null) { + continue; + } - if (str_contains($conditionField, '*')) { - $conditionField = $this->resolveWildcardConditionField($attribute, $conditionField); - if (str_contains($conditionField, '*')) { - continue; - } - } + if (! $canPreEvaluate || $firstExclusionIndex !== 0) { + $unresolvedAttributes[$attribute] = true; + continue; + } - if (array_intersect(['true', 'false', 'null'], $allowedValues) !== []) { - continue; - } + /** @var DelegatedCheck $firstCheck */ + $firstCheck = $plan->checks[0]; - $conditionRules = $this->rules[$conditionField] ?? []; - if (is_array($conditionRules) && in_array('boolean', $conditionRules, true)) { - continue; - } + try { + $parameters = $firstCheck->parameters; + $explicitKeys = []; + $dependsOnOtherFields = $this->dependsOnOtherFields($firstCheck->ruleName); - 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; - } + if ($dependsOnOtherFields) { + $explicitKeys = $this->getExplicitKeys($attribute); } - $actual = $cache[$conditionField]; - if ($actual === false) { - continue; - } + // 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]); - $shouldExclude = ($action === 'exclude_unless' && ! in_array($actual, $allowedValues, false)) - || ($action === 'exclude_if' && in_array($actual, $allowedValues, false)); + if (isset($exclusionOutcomes[$outcomeKey])) { + $passes = $exclusionOutcomes[$outcomeKey]; + } else { + if ($dependsOnOtherFields) { + $parameters = $this->replaceDotInParameters($parameters); - if ($shouldExclude) { - $this->preExcludedAttributes[$attribute] = true; - break; + if ($explicitKeys !== []) { + $parameters = $this->replaceAsterisksInParameters($parameters, $explicitKeys); + } + } + + $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; + 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; - } - return [$action, $args[0], array_slice($args, 1)]; - } + if (! $passes) { + $this->preExcludedAttributes[$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 ($hasLaterExclusion) { + $unresolvedAttributes[$attribute] = true; } - 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; - - return (string) preg_replace_callback('/\*/', static function () use ($indices, &$i) { - return $indices[$i++] ?? '*'; - }, $conditionField); + return $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. + * @param array $unresolvedExclusionAttributes */ - protected function maybeBatchDatabaseChecks(DatabasePresenceVerifier $presenceVerifier): void - { + protected function maybeBatchDatabaseChecks( + DatabasePresenceVerifier $presenceVerifier, + array $unresolvedExclusionAttributes, + ): void { if ($this->implicitAttributes === []) { return; } @@ -698,32 +698,69 @@ 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); + $exists = Arr::has($this->data, $attribute); - if ($meta === null) { + if (($plan->sometimes && ! $exists) + || $this->hasAttributeAncestorInSet($attribute, $unresolvedExclusionAttributes) + ) { + continue; + } + + $value = $this->getValue($attribute); + + if ($this->shouldSkipNonImplicitCheck($plan, $value, $exists) + || $this->shouldFailInvalidUpload($attribute, $value) + ) { + continue; + } + + foreach ($plan->checks as $index => $check) { + 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 +768,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 +779,41 @@ 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. - * - * Returns null if the rule is not exists/unique, not batchable (has - * closure callbacks, field-reference ignore), or can't be parsed. + * Extract batch metadata from a compiled database-presence check. * - * @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); - } - - // String-form or array-form: parse and extract using validator methods - [$ruleName, $parameters] = ValidationRuleParser::parse($rule); - - if (! is_string($ruleName)) { + * @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->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 +821,7 @@ private function extractPresenceRuleMeta(mixed $rule, string $attribute): ?array } $ignore = null; - $idColumn = $modelIdColumn ?? 'id'; + $idColumn = null; $wheres = []; if ($type === 'exists') { @@ -832,165 +855,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(); + private function canBatchPresenceCandidate( + AttributePlan $plan, + int $presenceIndex, + string $attribute, + mixed $value, + bool $exclusionsResolved, + ): bool { + for ($checkIndex = 0; $checkIndex < $presenceIndex; ++$checkIndex) { + $check = $plan->checks[$checkIndex]; - // Not batchable if has closure query callbacks - if ($meta['using'] !== []) { - return null; - } - - // 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; } /** @@ -1059,10 +962,20 @@ private function isPreExcludedOrDescendant(string $attribute): bool return true; } + return $this->hasAttributeAncestorInSet($attribute, $this->preExcludedAttributes); + } + + /** + * Determine if any strict ancestor of an attribute belongs to a set. + * + * @param array $attributes + */ + private function hasAttributeAncestorInSet(string $attribute, array $attributes): bool + { $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; From 34fbee15c4fa3f07d2ee73ec60193febef552d1c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:41:25 +0000 Subject: [PATCH 06/22] test(validation): cover rule compilation boundaries Pin generic fluent-rule canonicalization, callback-bearing object preservation, and single evaluation of conditional rules. Verify fluent in, not-in, and callback-free presence forms enter the compiled cache while custom and callback-bearing rules remain delegated. Cover value-dispatched size compilation, canonical numeric semantics including decimal rules, precision-safe threshold classification, removal of stale plan metadata, and zero-valued unique exclusions across the public fluent rule string. --- .../Validation/ValidationRuleCompilerTest.php | 228 ++++++++++-------- tests/Validation/ValidationRuleParserTest.php | 50 ++++ .../ValidationRulePlanCacheTest.php | 66 +++-- tests/Validation/ValidationUniqueRuleTest.php | 28 +++ 4 files changed, 260 insertions(+), 112 deletions(-) diff --git a/tests/Validation/ValidationRuleCompilerTest.php b/tests/Validation/ValidationRuleCompilerTest.php index 09c184feb..362ba15c2 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,7 +329,7 @@ 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]); @@ -346,8 +345,8 @@ public function __toString(): string } }; - $integerPlan = RuleCompiler::compile([['date_format', 123], 'after:124']); - $stringablePlan = RuleCompiler::compile([['date_format', $stringable], 'after:2025-01-01']); + $integerPlan = $this->compile([['date_format', 123], 'after:124']); + $stringablePlan = $this->compile([['date_format', $stringable], 'after:2025-01-01']); $this->assertSame('123', $integerPlan->checks[1]->param['format']); $this->assertSame('Y-m-d', $stringablePlan->checks[1]->param['format']); @@ -355,14 +354,14 @@ public function __toString(): string public function testMalformedArrayFormDateFormatDoesNotPoisonSiblingCompilation(): void { - $plan = RuleCompiler::compile([['date_format', []], 'after:2025-01-01']); + $plan = $this->compile([['date_format', []], 'after:2025-01-01']); $this->assertNull($plan->checks[1]->param['format']); } 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 +373,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 +389,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 +417,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 +438,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 +464,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,12 +474,12 @@ 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']); @@ -488,13 +487,12 @@ public function testCompileAllDelegatedProducesNoDelegatedChecks() $this->assertInstanceOf(DelegatedCheck::class, $check); } - $this->assertTrue($plan->required); - $this->assertTrue($plan->hasImplicitRule); + $this->assertCount(3, $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 +501,50 @@ 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 testFormatCheckTypesInline() @@ -528,7 +556,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 +565,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 +581,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 +589,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 +599,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 +612,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 +620,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 20f1ed4d3..83dfe2079 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 b398464a9..01ce232ea 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 22c5ce0ca..0d9c62427 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'); From 2763a98fc7b73d42c881b61f94a6f637a2aaa715 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:41:33 +0000 Subject: [PATCH 07/22] test(validation): cover ordered compiled execution Exercise the compiled executor's shared skip gates, invalid-upload handling, reviewed preflight allowlist, object and resource safety, value-dispatched sizes, and reset of transient numeric message state. Assert the allowlist partitions every inline check so new enum cases require an explicit repeat-safety decision. Add regressions for Laravel-compatible bail, implicit-rule, placeholder, nullable, sometimes, and global early-stop behavior. Cover strict date-format round trips and resource-valued JSON on both optimized base validators and delegated subclass execution. --- .../ValidationCompiledExecutionTest.php | 147 +++++++++++- .../Validation/ValidationPlanExecutorTest.php | 212 ++++++++++++++++-- tests/Validation/ValidationValidatorTest.php | 18 ++ 3 files changed, 355 insertions(+), 22 deletions(-) diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index 08f93395c..f3f0d25cd 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -17,6 +17,7 @@ use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; +use SplFileInfo; use stdClass; use Stringable; @@ -64,6 +65,109 @@ 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 testBailStopsOnFirstFailure() { $v = $this->makeValidator(['name' => 123], ['name' => 'bail|string|max:255']); @@ -72,6 +176,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 +427,7 @@ public function testSubclassWithOverriddenValidateStringIsNotBypassed() $this->assertFalse($v->passes()); } - public function testPreOptimizationGuardSkipsWithCustomExtensions() + public function testUnusedCustomExtensionPreservesExclusionBehavior(): void { $v = $this->makeValidator( ['type' => 'section', 'details' => 'test'], @@ -466,6 +592,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) { diff --git a/tests/Validation/ValidationPlanExecutorTest.php b/tests/Validation/ValidationPlanExecutorTest.php index 6fa07b077..d3a72932d 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/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index bf90f3551..34ad49506 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(); From abab0786cfa388ebc73c5cc10ed6b49a1cf040cc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:41:40 +0000 Subject: [PATCH 08/22] test(validation): cover database-semantic presence facts Cover query-shape isolation, raw PDO binding identity, Stringable normalization, unsupported boolean and date candidates, exact hits, proven absence, scalar-only known presence, ambiguous fallback, and execution-local fallback memoization. Pin the two-stage batcher's chunking and query counts, case and representation mismatches, distinct array-count safety, connection and unique-exclusion boundaries, callback delegation, verifier restoration, and the rule that one uncertain candidate cannot disable batching for safe siblings. --- .../ValidationBatchDatabaseCheckerTest.php | 225 ++++++++++-- ...idationPrecomputedPresenceVerifierTest.php | 323 +++++++++++++----- 2 files changed, 426 insertions(+), 122 deletions(-) diff --git a/tests/Validation/ValidationBatchDatabaseCheckerTest.php b/tests/Validation/ValidationBatchDatabaseCheckerTest.php index ba81b5c92..38eb58ddd 100644 --- a/tests/Validation/ValidationBatchDatabaseCheckerTest.php +++ b/tests/Validation/ValidationBatchDatabaseCheckerTest.php @@ -9,82 +9,242 @@ 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 testNormalizesOneDimensionalArraysAndCastsStringableValuesOnce(): 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, '3'], null, null, null, []) + ->andReturn(['1', '2', '3']); + + $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(1, $casts); + $this->assertSame(3, $verifier->getMultiCount('users', 'id', [1, 2, '3'])); } - public function testChunksLargeValueSetsBeforeCallingTheVerifier(): void + 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->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->assertNull(BatchDatabaseChecker::buildVerifier([ - 'users' => ['meta' => $this->metadata(), 'values' => [[1, new stdClass]]], - ], $presenceVerifier)); + $this->assertInstanceOf(PrecomputedPresenceVerifier::class, $verifier); + $this->assertSame(1, $verifier->getCount('users', 'id', 'exact')); + $this->assertSame(0, $verifier->getCount('users', 'id', 'missing')); } - public function testEmptyValueSetDoesNotCreateALookup(): void + 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 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 +254,7 @@ private function metadata(array $overrides = []): array 'column' => 'id', 'wheres' => [], 'ignore' => null, - 'idColumn' => 'id', - 'type' => 'unique', + 'idColumn' => null, ], $overrides); } } diff --git a/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php b/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php index e88cf64eb..0beea8d81 100644 --- a/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php +++ b/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php @@ -4,160 +4,305 @@ 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]); - - $this->assertSame(1, $verifier->getCount('users', 'id', '2')); - $this->assertSame(0, $verifier->getCount('users', 'id', '99')); + $this->assertNull(PrecomputedPresenceVerifier::lookupKey(null, 'users', 'email', extra: [static function (): void {}])); + $this->assertNull(PrecomputedPresenceVerifier::lookupKey(null, 'users', 'email', extra: ['status' => new stdClass])); } - public function testGetMultiCountCountsMatches(): void + public function testScalarFactsUseTheirDatabaseProvenState(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', ['a@b.com', 'c@d.com', 'e@f.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')); + } - $this->assertSame(2, $verifier->getMultiCount('users', 'email', ['a@b.com', 'c@d.com', 'missing@x.com'])); + public function testFactsRequireTheSubmittedBindingIdentity(): void + { + $stringable = new class implements Stringable { + public function __toString(): string + { + return '3'; + } + }; + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $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); + $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, $verifier->getCount('users', 'id', 1)); + $this->assertSame(0, $verifier->getCount('users', 'id', '2')); } - public function testFallbackUsedWhenNoLookupRegistered(): void + public function testUnknownScalarFallbackCountsAreMemoizedPerQueryShapeAndValue(): void { - $fallback = m::mock(PresenceVerifierInterface::class); + $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', 'foo@bar.com', null, null, []) + ->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->getCount('users', 'email', 'foo@bar.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 testFallbackUsedForGetMultiCountWhenNoLookup(): void + public function testUnregisteredAndUnsupportedScalarShapesDelegateWithoutMemoization(): void { - $fallback = m::mock(PresenceVerifierInterface::class); - $fallback->shouldReceive('getMultiCount') - ->with('users', 'email', ['a@b.com'], []) - ->once() + $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); $verifier = new PrecomputedPresenceVerifier($fallback); + $verifier->addLookup(self::lookupKey(null, 'users', 'email'), [], [], [], true); - $this->assertSame(1, $verifier->getMultiCount('users', 'email', ['a@b.com'])); + 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 testNoFallbackReturnsZero(): void + public function testConnectionIsPartOfTheLookupAndForwardedToTheFallback(): void { - $verifier = new PrecomputedPresenceVerifier; + $fallback = m::mock(DatabasePresenceVerifierInterface::class); + $fallback->shouldReceive('setConnection')->once()->with('tenant'); + $fallback->shouldReceive('getCount')->once()->with('users', 'email', 'unknown', null, null, [])->andReturn(1); - $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('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 testHasLookupsReturnsTrueWhenLookupsRegistered(): void + public function testMultiCountUsesExactAndAbsentFactsFromOneDistinctQuery(): void { - $verifier = new PrecomputedPresenceVerifier; - - $this->assertFalse($verifier->hasLookups()); - - $verifier->addLookup('users', 'email', ['foo@bar.com']); - - $this->assertTrue($verifier->hasLookups()); + $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'])); } - public function testNullValuesAreExcludedFromLookup(): void + public function testMultiCountUsesKnownPresentOnlyForASoleDistinctInput(): void { - $verifier = new PrecomputedPresenceVerifier; - $verifier->addLookup('users', 'email', [null, 'foo@bar.com', null]); - - $this->assertSame(1, $verifier->getCount('users', 'email', 'foo@bar.com')); - $this->assertSame(0, $verifier->getCount('users', 'email', '')); + $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']); - - $this->assertSame(0, $verifier->getCount('users', 'email', ['array'])); + $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', ['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', ['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()); + + $verifier->addLookup(self::lookupKey(null, 'users', 'email'), [], [], [], true); + + $this->assertTrue($verifier->hasLookups()); + } - $this->assertSame(2, $verifier->getMultiCount('users', 'email', $values)); + /** + * 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.'); } - public function testStringableValuesUsePrecomputedLookup(): void + /** + * 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 = []; + + foreach ($values as $value) { + $bindingKey = PrecomputedPresenceVerifier::bindingKey($value) + ?? throw new RuntimeException('Expected a supported presence value.'); + $bindings[$bindingKey] = true; + } - $this->assertSame(1, $verifier->getCount('users', 'email', $value)); + 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); } } From 6c50e9db47fa443fd9dbe0b922039a0926b04ac9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:41:46 +0000 Subject: [PATCH 09/22] test(validation): cover ordered exclusion preflight Verify that only a leading built-in exclusion can be pre-evaluated, earlier validation failures remain visible, and resolved non-excluding rules preserve presence batching. Exercise all five exclusion forms, nullable and sometimes flags, malformed parameters, mutation-capable rules, and descendant suppression. Pin dependent wildcard substitution through explicit capture keys, including literal numeric segments, multiple captures, escaped-dot attributes, nested arrays, and memoization boundaries so planning cannot drift from ordinary validation. --- .../ValidationPreEvaluatedExclusionsTest.php | 314 ++++++++++++++++-- 1 file changed, 286 insertions(+), 28 deletions(-) diff --git a/tests/Validation/ValidationPreEvaluatedExclusionsTest.php b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php index 8538eb312..6c6977801 100644 --- a/tests/Validation/ValidationPreEvaluatedExclusionsTest.php +++ b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php @@ -4,15 +4,19 @@ 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; class ValidationPreEvaluatedExclusionsTest extends TestCase { - public function testExcludeUnlessRemovesAttributeWhenConditionNotMet() + public function testExcludeUnlessRemovesAttributeWhenConditionNotMet(): void { $v = $this->makeValidator( ['type' => 'section', 'details' => 'some details'], @@ -23,7 +27,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 +38,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 +49,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 +60,29 @@ public function testExcludeIfKeepsAttributeWhenConditionNotMet() $this->assertArrayHasKey('publish_date', $v->validated()); } - public function testExcludeUnlessWithWildcardConditionField() + 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 +93,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 +101,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'], ); - $v->addExtension('custom_rule', function () { + $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('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 +262,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 +423,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 +450,7 @@ public function testPreExcludedWildcardAttributesRemovedFromValidatedOutput() $this->assertArrayNotHasKey('detail', $validated['items'][2]); } - public function testPreExcludedParentExcludesDescendantAttributes() + public function testPreExcludedParentExcludesDescendantAttributes(): void { $v = $this->makeValidator( [ From 6767aacee2a83de600218e61bbaec73e5d797041 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:41:54 +0000 Subject: [PATCH 10/22] test(validation): run batching across database drivers Move the shared presence-batching integration suite under the database workflow's package convention and expose it through thin MySQL, MariaDB, PostgreSQL, and SQLite wrappers. Keep all test bodies in one abstract case so every driver runs the same contract without duplication. Expand real-driver coverage for typed-column failures, early-stop transaction safety, case-insensitive and coercive equality, raw string-versus-integer bindings, DateTime grammar conversion, database DISTINCT behavior, callback fallback, chunking, and grouped query counts. --- .../ValidationBatchDatabaseCheckerTest.php | 13 + .../ValidationBatchDatabaseCheckerTest.php | 13 + .../ValidationBatchDatabaseCheckerTest.php | 13 + .../ValidationBatchDatabaseCheckerTest.php | 13 + ...ValidationBatchDatabaseCheckerTestCase.php | 1339 +++++++++++++++++ .../ValidationBatchDatabaseCheckerTest.php | 428 ------ 6 files changed, 1391 insertions(+), 428 deletions(-) create mode 100644 tests/Integration/Validation/Database/MariaDb/ValidationBatchDatabaseCheckerTest.php create mode 100644 tests/Integration/Validation/Database/MySql/ValidationBatchDatabaseCheckerTest.php create mode 100644 tests/Integration/Validation/Database/Postgres/ValidationBatchDatabaseCheckerTest.php create mode 100644 tests/Integration/Validation/Database/Sqlite/ValidationBatchDatabaseCheckerTest.php create mode 100644 tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php delete mode 100644 tests/Integration/Validation/ValidationBatchDatabaseCheckerTest.php diff --git a/tests/Integration/Validation/Database/MariaDb/ValidationBatchDatabaseCheckerTest.php b/tests/Integration/Validation/Database/MariaDb/ValidationBatchDatabaseCheckerTest.php new file mode 100644 index 000000000..06ee593e9 --- /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 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 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); + } +} diff --git a/tests/Integration/Validation/ValidationBatchDatabaseCheckerTest.php b/tests/Integration/Validation/ValidationBatchDatabaseCheckerTest.php deleted file mode 100644 index c8222c2ac..000000000 --- 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'; -} From 3934d3c141eac9f674c44aecf88290cb7084b9cd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:42:00 +0000 Subject: [PATCH 11/22] docs(validation): clarify exact date formats State beside the date_format rule that matching is exact, including the difference between padded and unpadded PHP date tokens. Keep the correction in the canonical validation documentation rather than adding a bug-fix entry to the Laravel porting guide or package README. --- src/docs/validation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index 4becb47dd..a6a819c71 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: From 9c830a69f1a3b0c77e6707ca89e40f2a93857d79 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:42:44 +0000 Subject: [PATCH 12/22] docs: sharpen Laravel porting guide criteria Define porting-guide entries by whether a Laravel porter must take action, and explicitly exclude ordinary bug fixes, internal implementation differences, contract-preserving performance work, incidental drift, and narrow edge cases unless they change that work. Remove vague examples such as hard boot failures, silent semantic differences, and package-specific details. Keep the guide's existing high-signal, concise, canonical-documentation requirements intact. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c4ea9ede4..090919a9a 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 From 6cb9538145e68bb7169c4ac3708dc0e96fa8e6dc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:44:08 +0000 Subject: [PATCH 13/22] docs(validation): finalize remediation plan Record the signed-off design for rule canonicalization, compiled execution, conservative preflight, ordered exclusions, database-semantic presence batching, strict date formats, benchmark integrity, and four-driver integration coverage. Keep the load-bearing correctness and performance invariants beside the implementation steps so future upstream validation ports can preserve Laravel's public behavior while adapting safely to Hypervel's cached, long-lived worker architecture. --- ...validation-audit-remediation-plan-codex.md | 469 ++++++++++++++---- 1 file changed, 373 insertions(+), 96 deletions(-) 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 index 29658f15d..b2d5be0d0 100644 --- 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 @@ -1,16 +1,16 @@ # Validation audit remediation plan -Status: Signed off by `claude-fixes` on 2026-08-22; ready for implementation +Status: Implementation complete; final code review pending -Branch: `audit/validation-remediation` from `0.4` at `7741eaad0435450e500304f173dde4f4a5488646` +Branch: `audit/validation-remediation` from `0.4` -Scope: master-audit findings 15–20 plus three validation defects exposed while reviewing their shared execution and compilation boundaries +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, single compiled execution loop, inline predicates, exclusion prepass, and wildcard database-presence batching. +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, single compiled execution loop, inline predicates, exclusion prepass, and wildcard database-presence batching. 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 maintenance registry of Laravel rule names. Execution-local facts may live only on the validator-owned verifier that is already installed for one `passes()` call. +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 @@ -22,9 +22,14 @@ The finished code must be the simplest design that is correct under Hypervel's l - **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 — closure-backed exclusion rules are parsed twice:** `RuleCompiler::compile()` parses every rule during its context scan and again during compilation, so `ExcludeIf` / `ExcludeUnless` conditions run twice and the first result is discarded. - -Out of scope: unrelated validation behavior, new validation APIs, removing pipe-delimited rules, or reverting the compiled validator. +- **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. ## Research and settled decisions @@ -38,7 +43,7 @@ The defects are optimizer-boundary mistakes, not flaws in the overall refactor. 4. `PlanExecutor` owns the one real validation loop; delegated rules still call 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 tests are green: 264 tests / 604 assertions across the six optimizer/compiler test files, and 18 tests / 38 assertions in the existing database batching integration file. 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. +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 @@ -51,11 +56,15 @@ 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 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 each rule once during ordinary execution. Hypervel's second parse is introduced solely by the compiled plan's context pre-scan and is unnecessary. +- 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. @@ -69,13 +78,81 @@ These invariants govern every change in this slice: - 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. -- Optimizer disqualification follows the ability to mutate this `Validator`'s data, not a wrapper interface that never reaches the wrapped rule. +- 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. - 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. Share the non-implicit execution gates +### 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: @@ -115,7 +192,7 @@ Tests: - 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. -### 2. Add a conservative inline-preflight boundary +### 3. Add a conservative inline-preflight boundary Files: @@ -129,7 +206,7 @@ Place `canPreflightInline(InlineCheck $check, mixed $value): bool` immediately b 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: +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) { @@ -137,7 +214,7 @@ if (! is_scalar($value) && ! $value instanceof Stringable) { } ``` -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. +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: @@ -152,17 +229,16 @@ StartsWith, EndsWith, DoesntStartWith, DoesntEndWith, In, NotIn, IsDate, DateFormat ``` -The size cases are safe only when they will not reach the user-configurable exponent guard: +The size cases are safe only when they cannot reach user code or a reachable Brick Math exception: ```php -! ( - $check->param['mode'] === SizeMode::Numeric - && is_numeric($value) - && Str::contains((string) $value, 'e', ignoreCase: true) -) +if ($check->param['numeric'] && is_numeric($value)) { + return ! Str::contains((string) $value, 'e', ignoreCase: true) + && (! is_float($value) || is_finite($value)); +} ``` -Objects are already rejected, so this also avoids file stat calls and magic string casts. Arrays continue through native `count()`. +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: @@ -172,56 +248,118 @@ Leave these eight cases unlisted: `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. -Add an optional fourth step to `CheckType`'s existing maintenance docblock: an inline case may be added to the preflight allowlist only after proving repeat evaluation is free of user callbacks, I/O, warnings, and reachable exceptions; omission is safe and only disables batching across that prefix. Do not add behavior or a second registry to the enum. +Add a fourth step to `CheckType`'s existing maintenance docblock: an inline case may be added to the preflight allowlist only after proving repeat evaluation is free of user callbacks, I/O, warnings, and reachable exceptions; omission is safe and only disables batching across that prefix. Do not add behavior or another rule-name registry to the enum. Tests: -- assert every allowed and disallowed case, the object/resource guard, array support, and the size exponent exception; +- 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 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. -### 3. Build presence candidates from active compiled plans in order +### 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 9 +- 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. Skip plans whose `sometimes` flag is set when the concrete key is absent. -3. Locate each `Exists` / `Unique` `DelegatedCheck` in the concrete plan and extract metadata from its `originalRule`, preserving string, array, and rule-object forms. -4. Apply the shared non-implicit and invalid-upload predicates to the current value. -5. Walk only the checks preceding that presence check, in declaration order: +3. Apply the shared non-implicit and invalid-upload predicates once per attribute, before the check loop; neither depends on the current check or index. +4. 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. -6. Preserve an active uncertain query shape for collision detection, but add no value for it. If all candidates are uncertain, no batch query or verifier swap occurs. + Use an indexed walk over the plan's list rather than allocating an `array_slice()` for every candidate. +5. 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. +6. 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. +7. 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 -> retain active shape, do not submit; runtime fallback if reached +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 mode is compiled from the sibling `integer`, but value-first size dispatch treats `'abc'` as length 3, so `min` passes and `integer` fails. Stopping at the unsafe size rule and submitting the raw value would be wrong. +- `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. Continue using the existing full-query-shape key and conservative table/column collision guard; a possibly executable uncertain shape must still prevent another shape on the same table/column from intercepting its runtime probe. +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` can still make an already-issued batch query unnecessary when an earlier attribute later fails. This is an existing consequence of pre-execution batching and does not justify stateful or phased machinery. +`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: @@ -229,24 +367,39 @@ Tests: - 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; - 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, array-tuple, `Exists`, and `Unique` object forms retain their metadata and messages. +- string and array-tuple forms plus canonicalized callback-free presence objects retain their metadata/messages; callback-bearing objects remain delegated. -### 4. Track the real optimizer mutation surface +### 6. Split optimizer gates at the real mutation boundary Files: - `src/validation/src/Validator.php` - `tests/Validation/ValidationCompiledExecutionTest.php` -Correct `compiledPlansContainValidatorAwareRules()` by unwrapping `InvokableValidationRule`: +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->ruleObject instanceof InvokableValidationRule) { - if ($check->ruleObject->invokable() instanceof ValidatorAwareRule) { +if ($check->originalRule instanceof InvokableValidationRule) { + if ($check->originalRule->invokable() instanceof ValidatorAwareRule) { return true; } @@ -254,18 +407,27 @@ if ($check->ruleObject instanceof InvokableValidationRule) { } ``` -The wrapper always implements `ValidatorAwareRule`, but forwards the validator only when the inner rule implements it. A normal modern `ValidationRule` / `InvokableRule` cannot mutate this validator and must not globally disable exclusion or presence optimization. +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 a blocker because it passes the live validator as the fourth callback argument. Keep an actual inner `ValidatorAwareRule` as a blocker. Regardless of the global decision, a custom rule preceding presence remains locally uncertain under step 3. +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 unrelated plain modern validation rule does not prevent wildcard batching; -- an inner `ValidatorAwareRule` still disables precomputation; -- a closure rule still disables precomputation; -- mutation before presence retains ordinary execution semantics. +- 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. -### 5. Make precomputed presence facts database-semantic +### 7. Make precomputed presence facts database-semantic Files: @@ -275,45 +437,87 @@ Files: - `tests/Validation/ValidationDatabasePresenceVerifierTest.php` - `tests/Validation/ValidationBatchDatabaseCheckerTest.php` - `tests/Validation/ValidationPrecomputedPresenceVerifierTest.php` -- validation database integration tests described in step 9 +- 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. -#### 5.1 Query only normalizable candidates +#### 7.2 Query only normalizable candidates -Normalize each concrete candidate independently. Strings, integers, floats, `Stringable` values, and one-dimensional arrays containing only those types remain supported. Booleans, null, and other unsupported candidates are skipped without declining safe siblings. Unsupported runtime probes must delegate before consulting any stored fact. +Normalize each concrete candidate independently. Strings, integers, floats, ordinary `Stringable` values, and one-dimensional arrays containing only those types remain supported. Booleans, null, `DateTimeInterface`, and other unsupported candidates are skipped without declining safe siblings. Date/time objects must delegate because `Connection::prepareBindings()` formats them through the connection grammar rather than their `__toString()` method. Unsupported runtime probes must delegate before consulting any stored fact. -Keep query binding semantics separate from lookup keys. Do not string-cast every submitted SQL value: retain the raw string, integer, or float query value so the connection performs the same driver-specific binding as the ordinary verifier; cast a supported `Stringable` once. Alongside it, build the same type-insensitive `(string)` lookup key that `PrecomputedPresenceVerifier` uses today. This prevents the batch path from changing a typed PostgreSQL probe merely to deduplicate it without assuming that PDO will return the same PHP type it received. +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; cast a supported `Stringable` once. `Connection::bindValues()` binds integers as `PDO::PARAM_INT` and the other supported values as `PDO::PARAM_STR`, so add 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 candidates and fetched database values. The binding key delegates to it, so support checks and `Stringable` conversion remain in one place. Float, string, and a cast `Stringable` intentionally share the `s` form because PDO binds each as the same 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 that string key and retain the first raw value as the representative SQL binding. Use the identical key function for submitted candidates, runtime probes, and every value fetched by both query stages before comparing results or populating `exactHits`, `knownPresent`, and `provenAbsent`. Integer/float candidates may be returned as strings because of the column type, driver, or PDO options; equal string keys must remain fast-path hits rather than becoming ambiguous misses. Booleans are excluded before normalization, so `false` cannot collide with an empty string. Keep the representation as plain arrays/maps rather than introducing a value object. +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. Reuse that suffix as an ordinary `Stringable` value's representative so `__toString()` runs once. 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. Booleans and date/time objects 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. -#### 5.2 Use two grouped stages +#### 7.3 Use two grouped stages -For every collision-free query shape: +For every grouped query shape: -1. Query all distinct submitted representative values in chunks of 1,000. -2. Normalize every fetched value through the shared string-key function, then build the exact-key hit map and submitted-key misses. +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 distinct submitted value and stage 1 returned a nonexact representation, that sole value is already known present for scalar presence semantics; no isolation query is needed. -5. 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. -6. If stage 2 fetched nothing, every submitted miss is proven absent. -7. Every miss returned with the same normalized key 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. -8. If stage 2 is non-empty and there was exactly one distinct miss, that isolated miss is known present even when the stored representation differs. -9. Other misses from a non-empty multi-miss stage remain unresolved and each runtime scalar probe delegates, memoized per normalized value for this execution. +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 lookup: +Store three plain maps per query-keyed lookup: ```text -exactHits stage 1 returned this exact normalized string key -knownPresent a single-input query or stage 2 proved a scalar miss is present -provenAbsent a stage returned no row for this submitted value +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. Return the real cached integer count, not a synthesized boolean. +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: @@ -325,16 +529,16 @@ Expected scalar query costs: 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. -#### 5.3 Preserve `getMultiCount()` database DISTINCT semantics +#### 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. After normalizing and deduplicating the requested array, `getMultiCount()` follows these rules: +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` value is usable only when it is the array's sole distinct input; otherwise delegate because it may map to the same stored value as another input; +- 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. @@ -346,7 +550,8 @@ Tests: - exact, absent, known-present, unresolved, unsupported, and unregistered scalar paths; - 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; -- fallback count memoization is execution-local and keyed by distinct normalized scalar probe; +- `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; @@ -356,19 +561,22 @@ Tests: - 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 integer candidates against text columns and numeric/decimal column results; +- 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. -### 6. Restore exclusion order and wildcard authority +### 8. Restore exclusion order and wildcard authority Files: - `src/validation/src/Validator.php` - `tests/Validation/ValidationPreEvaluatedExclusionsTest.php` -Drive `preEvaluateExclusions()` from `compiledPlans`. Only pre-evaluate when `checks[0]` is a delegated `ExcludeIf` or `ExcludeUnless`. `nullable`, `bail`, and `sometimes` are plan flags rather than executable checks, so they do not occupy position zero. +Drive exclusion analysis from `compiledPlans`. Only pre-evaluate when `checks[0]` is a delegated exclusion rule. `nullable`, `bail`, and `sometimes` are plan flags rather than executable checks, 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`. When a first-position exclusion resolves non-excluding and the attribute survives the pre-excluded filter, let that attribute's presence-prefix walk skip the resolved exclusion. Exclusions marked unresolved by position, malformed parameters, or the mutation gate remain uncertain and keep ordinary per-value presence execution. Use the check's already parsed parameters. Apply the same dependent-field normalization as `validateAttribute()`: @@ -380,18 +588,32 @@ if ($keys = $this->getExplicitKeys($attribute)) { } ``` -Retain the existing safety skips for boolean/null-dependent coercion and non-scalar condition values. Delete `parseExcludeRule()` and the numeric-segment regex `resolveWildcardConditionField()` once they have no caller. +After parameter normalization, call the corresponding existing `validateExclude*()` predicate without adding a failure; a false result means pre-exclude and a true result resolves non-excluding. This reuses `parseDependentRuleParameters()` and therefore handles boolean/null coercion and non-scalar values exactly like execution instead of maintaining the current manual approximation. The exact-base/no-used-mutator gate makes repeated access to `$this->data` safe. + +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, so its inputs cannot change during its lifetime and it adds no plan, coroutine, or worker state. Keep calling `getValue($attribute)` on misses to match normal invocation. Do not cache exception deferrals, which would add a third state for a rare path, and do not add plan-identity metadata or a broader `getExplicitKeys()` pattern cache. + +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 a plain set of potential parent exclusions for step 5. A first-position exclusion whose predicate returned false is pre-excluded; a true result is resolved non-excluding. An exclusion later in its plan, a malformed exclusion deferred after `InvalidArgumentException`, or any exclusion while the mutation gate is closed remains unresolved. Pass that local set into presence candidate building; do not store execution order or add a validator property. A candidate declines batching only when an unresolved exclusion attribute is a strict ancestor, while its own rule-prefix walk remains the authority for same-attribute order. Reuse the existing O(depth) prefix walk rather than scanning every unresolved attribute for every candidate. 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`; -- one/multiple wildcard captures, mismatched counts, nested arrays, and escaped-dot field names match normal dependent-rule execution; +- 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. -### 7. Reset transient numeric state, parse once, and remove dead plan metadata +### 9. Reset transient state, parse once, and streamline stop checks Files: @@ -411,7 +633,7 @@ Delegated checks already reset inside `validateAttribute()`. Do not add a dirty 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 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. Do not cache object parse results beyond this compile: `ExcludeIf` and `ExcludeUnless` deliberately evaluate their closures while stringifying, so a worker-global object cache would be incorrect. This removes duplicate parsing work and ensures a closure-backed exclusion condition is evaluated once per compile, with the result that is actually compiled. +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: @@ -429,33 +651,46 @@ foreach ($rules as $index => $rule) { 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 pair-consuming path, keep `collectContext()`'s `is_string($parsedName)` guard, and consume the parsed name/parameters only for the remaining string, array, and `Stringable` forms, including `Exists` / `Unique`. +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, keep `collectContext()`'s `is_string($parsedName)` guard, and consume the parsed name/parameters for strings, array tuples, and callback-bearing `Stringable` presence objects. `compileAllDelegated()` does not need parsed context after the dead size-mode metadata is removed. Let `compileRuleDelegated()` continue parsing each rule once as it emits the delegated check; do not add a second generalized compilation abstraction solely to share a short control flow. -Then remove from `AttributePlan`: +Remove from `AttributePlan`: - `$required`; - `$hasImplicitRule`; -- `$sizeMode` and its `SizeMode` import. +- `$sizeMode` and its `SizeMode` import as part of step 4. -Remove every compiler write and delete `RuleCompiler::isImplicitRule()` with its duplicate list. Keep size mode only in `compile()`'s local context and in the `InlineCheck` parameters that consume it. `compileAllDelegated()` no longer needs the context pre-scan, which also removes needless work for validator subclasses. +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. -Replace compiler tests that pin dead fields with behavior or consumed-output assertions: +The compiled loop must also stop from state it already owns instead of 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 the legacy benchmark loop and protected API compatibility; only the compiled executor bypasses its repeated parsing. + +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 mode come from the emitted inline check; -- closure-backed `ExcludeIf` and `ExcludeUnless` conditions are invoked exactly once per `compile()` call, and their single result determines the emitted check; +- 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; - all-delegated subclass plans retain correct ordered behavior without stored context. -### 8. Fix strict `date_format` round trips +### 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` @@ -482,7 +717,9 @@ Tests in both base and all-delegated execution paths: Mark this as an upstream Laravel issue/PR candidate after the Hypervel fix; do not make upstream coordination part of this implementation slice. -### 9. Put database validation coverage on the existing matrix +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: @@ -512,18 +749,50 @@ Update `tests/Validation/ValidationDatabasePresenceVerifierTest.php::testGetExis 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; +- 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/ValidationRuleCompilerTest.php \ + tests/Validation/ValidationRuleParserTest.php \ + tests/Validation/ValidationRulePlanCacheTest.php vendor/bin/phpunit tests/Validation @@ -533,13 +802,13 @@ bin/run-database-tests.sh mariadb --filter=ValidationBatchDatabaseCheckerTest bin/run-database-tests.sh pgsql --filter=ValidationBatchDatabaseCheckerTest ``` -Run the existing benchmark before source changes and after the final implementation, using multiple runs rather than one noisy sample: +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 the flat and simple cases affected by the inline numeric-state reset. Presence performance is pinned primarily by query counts because avoided database round trips dominate predicate CPU. +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: @@ -551,22 +820,30 @@ Do not weaken assertions to accommodate the implementation. Any failure must be ## Acceptance checklist -- [ ] Laravel rule syntax, public APIs, rule/message order, and extension points remain compatible except for the verified upstream `date_format` and resource-valued `json` bug fixes. +- [ ] 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. - [ ] Pipe-delimited and array rule forms compile to the same correct behavior. +- [ ] Laravel's generic safe-object canonicalization is restored; fluent `in` / `not_in` and callback-free presence rules use caching/inlining without a class allowlist. +- [ ] Callback-bearing presence rules retain their objects and query callbacks, remain delegated, and produce no lookup key. - [ ] O(n) wildcard expansion, immutable worker-lifetime plan caching, and the single execution loop remain intact. -- [ ] Common `required|integer|exists`, `email|exists`, `date|exists`, and `required|array|exists` wildcard shapes remain batched. +- [ ] Typeless `min` / `max` / `size` / `between` inline with runtime value dispatch; numeric semantics come only from the canonical `$defaultNumericRules`, including `Decimal`. +- [ ] Common `required|integer|exists`, `required|max:255|exists`, `email|exists`, `date|exists`, and `required|array|exists` wildcard shapes remain batched. +- [ ] `stopOnFirstFailure` uses ordinary presence execution so speculative SQL cannot replace the first validation failure or poison a PostgreSQL transaction; exclusion pre-evaluation remains enabled. - [ ] No invalid value is submitted merely because preflight could not prove its prefix; uncertain probes fall back only if execution reaches presence. - [ ] Boolean presence candidates use the ordinary verifier path; driver-specific binding is never approximated by the batch optimizer. -- [ ] SQL bindings retain their raw supported types while candidates, runtime probes, and fetched values share one PDO-type-insensitive string lookup key. -- [ ] Pre-excluded, absent-sometimes, empty, nullable, and proven-failing values issue no presence query. +- [ ] 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. +- [ ] 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. +- [ ] Pre-excluded, unresolved-parent-excluded, absent-sometimes, empty, nullable, and proven-failing values issue no presence query. - [ ] Case-insensitive/collation-equivalent `unique` values cannot false-pass. - [ ] Array-valued `exists` agrees with database `DISTINCT` semantics, including chunk boundaries. - [ ] No optimizer result is stored in a shared plan, static property, or coroutine context. -- [ ] Exclusion pre-evaluation preserves earlier failures and resolves wildcard captures through the established authority. +- [ ] 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. +- [ ] Unused extensions do not suppress optimization; used validator mutators suppress only exclusion pre-evaluation and affected descendant batches. - [ ] Inline messages cannot inherit transient numeric state. - [ ] Resource-valued JSON fails cleanly in inline and delegated execution, with no duplicate JSON predicate. -- [ ] Closure-backed exclusion rules are parsed/evaluated once per compile. -- [ ] Dead plan fields, compiler writes, duplicate implicit-rule knowledge, and obsolete helpers/tests/comments are removed. +- [ ] Conditional fluent closures are evaluated once during parser explosion, matching Laravel, and compiler context/emission share one local parsed-pair list. +- [ ] Compiled bail/uploaded/implicit stopping uses existing plan/failure state and placeholder-cleaned attribute keys without reparsing rules. +- [ ] `SizeMode`, `DatabasePresenceRule`, presence-metadata methods, dead plan fields/compiler writes, duplicate implicit-rule knowledge, and obsolete helpers/tests/comments are removed. - [ ] Every retained source comment and docblock describes the final design; no superseded optimizer explanation remains. - [ ] The validation database suite runs through the existing MySQL, MariaDB, PostgreSQL, and SQLite workflow discovery. +- [ ] 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. - [ ] Focused, full validation, database-matrix, benchmark, static-analysis, formatting, and final repository checks pass. From 53de6ef1a91c847364bc39b9eab4a35e64b15a06 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:48:44 +0000 Subject: [PATCH 14/22] docs(validation): remove master remediation ledger Keep this branch focused on the validation implementation and its dedicated validation plan. The general components audit ledger now lives canonically on branch 0.4, so retaining a branch-owned copy would duplicate ownership and leave unrelated remediation guidance in the validation change set. --- ...ponents-04-audit-remediation-plan-codex.md | 320 ------------------ 1 file changed, 320 deletions(-) delete mode 100644 docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md diff --git a/docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md b/docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md deleted file mode 100644 index 7b95030e5..000000000 --- a/docs/plans/2026-08-22-0604-components-04-audit-remediation-plan-codex.md +++ /dev/null @@ -1,320 +0,0 @@ -# Hypervel Components 0.4 Audit Remediation Plan - -Status: Signed off by `claude-fixes` on 2026-08-22; ready for implementation - -## Objective - -Resolve every genuine issue in the August 18 components audit against the current 0.4 branch. Hypervel 0.4 is greenfield: backward compatibility with earlier Hypervel releases, churn, and patch size are not constraints. Preserve Laravel's current canonical APIs unless Hypervel's coroutine, pooled-resource, or long-lived-worker architecture requires a better contract, or preserving the surface would impose disproportionate machinery or materially worse code. Do not import names, aliases, shims, or duplicate paths retained solely for Laravel's historical backward compatibility. Prefer fail-fast behavior, explicit lifecycle ownership, and source-level fixes over compatibility shims. - -## Final verdict - -- 151 unique findings were substantively valid at audit time. -- 150 unique findings remain open on the current branch. -- Finding 123 was valid but is already fixed on the current branch. -- Findings 4, 9, 14, 93, 109, 119, 129, and 153 require no change: some are false positives, while the rest propose churn or machinery for deliberate behavior that is already correct. -- Finding 88 is an exact duplicate of finding 32 and must not become a second patch. -- Findings 6, 26, 41, 55, 65, 94, 98, 108, 128, 145, 152, 154, and 158 are only partially correct as written. Their valid portions remain in this plan; their invalid portions are explicitly rejected below. -- Some audit statements about Laravel were stale or incorrect. A defect shared with current Laravel remains a defect, but the plan does not cite false upstream parity as evidence. - -## Rejected, duplicate, resolved, and narrowed claims - -| ID | Disposition | Reason | -|---:|---|---| -| 4 | False positive | The audit's claimed Laravel divergence does not exist. Hypervel and current Laravel have the same selector grammar, and runtime checks against both confirmed that raw selectors such as { 1 } and [1, 19] do not match. There is no documented Laravel API requiring the proposed whitespace extension. | -| 6 | Partially confirmed | ViewException::render returning only Response or null is too narrow and must become mixed. ViewException::report returning bool or null is the meaningful Laravel exception contract and must not be widened to arbitrary values. | -| 7 | Confirmed with stale wording | The current cache key is already xxh128, not raw Blade source. The unbounded worker cache and missing source-file recreation after view:clear remain real. | -| 9 | False positive | lastFragment is written but never read. It has no observable behavior and therefore cannot leak a fragment between coroutines. Remove it only if a later compiler cleanup naturally touches the code; do not create a standalone remediation. | -| 14 | False positive | Swoole's response fd is a generated connection SessionId, not an immediately reusable OS socket descriptor. Allocation advances session_round, skips occupied session slots, and verified lookup checks both session and connection identity. The stale-close and delayed-handshake fd-reuse races described by the audit are therefore unreachable. Current Swoole master retains the same invariant, and this investigation exposed no Swoole defect. | -| 32 | Confirmed | This is the canonical keyed-resource-collection issue. | -| 65 | Partially confirmed | There is no missing `x-oauth-2` driver: Hypervel intentionally exposes only the modern OAuth 2 `x` driver and omits legacy Twitter/OAuth 1 support. Current Socialite's `services.x-oauth-2` fallback is a historical configuration alias retained for backward compatibility, not a second driver or modern canonical surface. Remove that fallback and support only `services.x`; do not add an alias or OAuth 1 driver. | -| 88 | Exact duplicate | Same file, cause, behavior, and fix as 32. Cover it with the 32 tests and close both audit IDs together. | -| 93 | Rejected | Moving methods to a preferred class location is style-only churn. Narrowing the concrete path() return to string would needlessly diverge from Laravel's nullable signature and could make existing subclasses incompatible. | -| 94 | Partially confirmed | Native concrete JsonSchema type returns in hypervel/contracts create an invalid reverse dependency. The contract being unbound is not a defect: Laravel likewise does not bind it. Preserve the Laravel contract API by matching upstream's untyped methods with precise PHPDoc; do not add a service provider or binding. | -| 108 | Partially confirmed | Native posix_kill reports failure with false and that result is currently ignored. The catch is not literally unreachable because an overridden signalProcess may throw. Handle both false and Throwable. | -| 109 | Rejected | A metadata-only prescreen can miss a same-size rewrite with restored/coarse timestamps; periodic full rehashing merely delays correctness and adds a tuning constant. Full hashing is the default driver's deliberate correctness guarantee, and finding 105 separately fixes the accidental broad-root scan. | -| 119 | False positive | The defaults on tinker.alias, tinker.dont_alias, and tinker.commands intentionally allow those lists to be removed; TinkerCommandTest explicitly pins that behavior. Typed getters without defaults would break a supported configuration. | -| 123 | Valid, already resolved | RedisConnection::callGet is mixed on the current branch. Retain or extend the serializer regression test, but do not schedule another production change. | -| 128 | Partially confirmed | The strict native type causes the reported TypeError. Match Laravel's clean assertion behavior by accepting null in the JSON assertion methods; do not invent a new undocumented “any errors” meaning for assertOnlyJsonValidationErrors. | -| 129 | Rejected | Framework reset methods are no-throw lifecycle boundaries by design, and the subscriber already preserves the first error across its explicit outer cleanup stages. Wrapping roughly two hundred static resets in per-call fault-isolation machinery optimizes for unsupported throwing reset implementations and weakens the simple at-most-once cleanup contract. | -| 145 | Partially confirmed | The five named defects are real. The aside about adding number, autocomplete, and data-table methods to FormBuilder is an unspecified product expansion, not an audited defect, and is excluded. | -| 152 | Partially confirmed | Keep the token, migration, teardown, and duplicate-resolution fixes. Purely numeric PHP array keys cannot be made string keys by casting because PHP coerces them back to integers, so filter those irrelevant keys from the subprocess environment map. Reject the first-caller descriptor and ConfigContract expansion: supported boot paths initialize the process-global YAML cache consistently and Bootstrapper intentionally requires its concrete Config implementation. | -| 153 | Rejected | Immediate hard termination is deliberate, documented, and pinned by testKillDoesNotWaitForUnrelatedActiveJobs. The timed-out coroutine is not cancellable, so the worker is poisoned; draining siblings delays the hard timeout while the stuck job can continue side effects. | -| 154 | Partially confirmed | Remove the never-supported SQL Server branch and document the Redis script argument. Keep raw usleep: Swoole hooks make it coroutine-friendly, queue tests already override Worker::sleep, and Support\\Sleep adds avoidable allocation/global-fake machinery to polling paths. The 1ms shutdown-only empty check does not justify a new Concurrent notification API. | -| 158 | Partially confirmed | Response::cookies has the reported nullable-return defect. The two protected reusable-client methods are part of Laravel's subclass surface and cause no defect while unused; retain them rather than deleting API for cleanup alone. | - -## Cross-cutting design decisions - -1. Worker defaults and request overrides are different state classes. Laravel-style setters called before `Hypervel\Contracts\Foundation\Application::isBooted()` update a worker baseline. Calls after boot during an execution use CoroutineContext, or a dedicated withX callback performs a scoped override. In standalone use without an Application, retain the documented package fallback; never infer boot from coroutine presence. -2. A pooled client or database connection may be retained only as a pool/factory/resolver handle. A borrowed connection, stream, or client must remain inside its borrow scope. -3. Shared cache publication follows database transaction visibility. The mutating execution bypasses shared caches while its relevant transaction is dirty; shared invalidation happens after commit and never after rollback. Use atomic cache publication where it completely orders fills against mutations; use a per-identity lock only where invalidation cannot publish an authoritative value. Hits remain lock-free. -4. Correctness optimizations must have conservative eligibility checks and a delegated fallback. A fast path may never change database comparison semantics, validation order, or callback behavior. -5. Worker-lifetime caches require a natural finite keyspace or deterministic invalidation. Cheap input-derived values should not be retained worker-wide merely to avoid parsing or hashing them, and arbitrary caps are not a substitute for correct ownership. -6. Fail loudly for unsupported or ambiguous configuration. Do not silently clamp, coerce, fall back to inaccurate readers, or accept an API surface that cannot work. -7. Every concurrency fix needs a deterministic interleaving test, not only a sequential unit test. - -Record the boot-baseline/execution-override semantic shared by Notification, Number, and Sentry once in `src/docs/porting-from-laravel.md`. Record the intentional removal of Socialite's legacy `services.x-oauth-2` configuration fallback there as well. - -## Architecture, compatibility, and cost guardrails - -- Preserve Laravel's current canonical method names, valid-input semantics, contracts, facades, constructor call forms, container aliases, and extension points. Diverge when Hypervel's architecture requires it or when parity would demand disproportionate machinery/workarounds and produce materially worse code; choose the simplest well-adapted contract in that case and document the divergence. Do not inherit aliases, deprecated names, compatibility shims, or implementation residue that current Laravel retains solely for historical backward compatibility; Hypervel 0.4 should expose the modern canonical surface directly. Establish that something is genuinely legacy before removing it—an apparently unused current extension point is not enough. The plan may also reject previously accepted invalid or misleading states with a descriptive exception. -- Apply that rule consistently: Horizon uses `vonage`, never upstream Horizon's stale `nexmo` name; Socialite exposes one `x` OAuth 2 driver configured by canonical `services.x`, without the legacy `services.x-oauth-2` fallback; legacy Twitter/OAuth 1 remains omitted. -- Hypervel worker singletons retain only boot-time immutable/baseline state. Request, job, command, and test overrides belong in CoroutineContext and are cleaned at execution boundaries. This is an architectural adaptation, not a port of Laravel's process-per-request mutable-static assumptions. -- Framework-owned pooled database/auth objects retain resolvers and names, never borrowed connections. The Laravel-compatible direct ConnectionInterface constructor form remains available for non-pooled callers and test doubles. -- Most fixes remove work, bound memory by correct lifetime, preserve a fast path, or add only constant-time validation/state checks. Database-semantic fallbacks in 15-16 run only when the optimization cannot prove equivalence. -- Findings 43 and 57 coordinate only cache misses/mutations; their cache-hit paths remain lock-free. Findings 78-79 replace an unverifiable freshness shortcut with one authoritative row read per existing node at a structural mutation boundary; ordinary tree reads remain unchanged. Reverb recovery is an operator command with zero steady-state cost. Queue timeout behavior remains unchanged because the proposed drain would weaken its safety contract. -- No fix may add periodic polling, arbitrary eviction thresholds, a distributed lock on every request/cache hit, metadata shortcuts with delayed correctness, or a new abstraction whose only purpose is an unsupported failure mode. -- Performance and scalability claims must be measured during implementation for the affected hot paths. A fix does not land if its package benchmark/load test shows a material regression that is not inherent to the required correctness guarantee; revise the design instead. -- Load tests must cover high-cardinality input and long worker lifetimes, not only request latency: retained memory must converge to the natural live/configured keyspace, and temporary request/job state must disappear at execution teardown. -- Compare database queries, remote calls, lock acquisition, bytes copied over IPC, allocations, and coroutine scheduling before and after each affected hot-path fix. Cache hits and ordinary non-strict/read paths must remain lock-free and free of new I/O. -- No synchronous CPU or blocking-I/O work may be moved onto the event loop. Where an existing public API is inherently synchronous, keep its implementation lean and document task-worker/queue offload for heavy workloads rather than hiding an unbounded background mechanism inside the framework. - -## Complete remediation ledger - -Each row is an implementation requirement. Test names are descriptive; use the repository's established test file for that component or create the narrowly corresponding file. - -### Core pools, translation, views, filesystem, websocket, and validation - -| ID | Proposed implementation | Required tests | -|---:|---|---| -| 1 | Change PoolFingerprint's internal canonical-config digest from sha256 to xxh128, matching the repository's non-cryptographic fingerprint convention. Keep canonical key ordering and scalar normalization unchanged. | Exact algorithm and digest-length test; equivalent reordered configuration test; distinct normalized configuration test. | -| 2 | Stop automatically retaining every parsed key on NamespacedItemResolver. Keep setParsedKey and flushParsedKeys exactly as the public explicit cache API, but parse ordinary keys directly; the explode/str_contains work is cheaper and safer than a per-call context lookup or an arbitrary worker cache cap. | Arbitrary validation/translation keys do not grow worker state; explicitly seeded parsed keys still hit and flush; parse output remains identical; a focused microbenchmark confirms the uncached parser is not a material translation regression. | -| 3 | Keep successful translation groups in the worker cache, but store empty/missing locale-group results only in execution-local negative state. This avoids permanent attacker-driven locale growth without repeating filesystem probes inside one request/job. | Thousands of missing locales leave worker loaded state unchanged; one execution probes a missing/legitimate-empty group once; a later execution can discover a newly added translation; positive groups remain worker-cached. | -| 4 | No change; see disposition above. | Preserve existing selector tests. | -| 5 | Extract `Translator::get()`'s lookup body into a protected internal method with separate substitution replacements and missing-key-callback replacements. `get()` passes `$replace` for both; `choice()` passes `[]` for substitution and the caller's `$replace` to the missing callback, then substitutes only after plural segment selection. Keep every public signature unchanged and do not duplicate lookup. | Callback receives locale, key, and exact replacements; a replacement containing a pipe does not alter plural selection; normal translation replacement remains once-only. | -| 6 | Widen ViewException::render to mixed and forward all native Laravel exception render results. Keep report as bool or null. | String, array, View, Responsable, Response, and null render forwarding; bool/null report forwarding; original exception behavior when methods are absent. | -| 7 | Retain the worker cache only for existing named views, whose keyspace is application-defined. For raw inline component source, derive the deterministic xxh128 view name and keep only execution-local reuse; ensure the source file exists on the first use in that execution. Make view:clear clear the execution-local marker so an immediate re-render recreates the source. Do not add an arbitrary eviction cap. | Named views reuse worker state; thousands of unique inline sources do not grow the worker map; repeated inline render in one execution avoids repeated stats; delete/view:clear then render recreates the source; no cross-component collision. | -| 8 | Preserve the public abstract Engines\Engine base, but make getLastRendered return nullable string to match its initialized state. | Anonymous concrete subclass returns null before render and the rendered path afterward. | -| 9 | No standalone change; see disposition above. | None. | -| 10 | Remove the s3/gcs client-only match arms from whole-disk pool definitions. A custom whole-disk creator fingerprints the logical disk name plus its complete normalized config unless it explicitly supplies its own fingerprint. Built-in S3/GCS client pools keep their client-specific fingerprints. | Two custom S3 or GCS creators with identical client credentials but different bucket/root/name never share a disk pool; built-in clients still share only when safe; explicit fingerprint override works. | -| 11 | Wrap the positioned resource with GuzzleHttp\Psr7\Utils::streamFor, GuzzleHttp\Psr7\LimitStream, and StreamWrapper::getResource so the base readStreamRange enforces its end offset without buffering or a custom stream implementation. Add `guzzlehttp/psr7` as a direct filesystem dependency instead of relying on `hypervel/http` to provide it transitively. | Closed, open-ended, and suffix ranges; seekable and non-seekable sources; zero/one-byte boundaries; returned value remains a PHP resource; close propagation; nested leased pooled stream remains borrowed until wrapper close; standalone filesystem dependency/autoload check. | -| 12 | Model signed-route ownership separately from a scoped/on-demand adapter's serve flag. Named scoped disks over a served parent use the parent's route and accumulated prefix; nested scopes compose prefixes. Anonymous build disks cannot advertise the global named route and must fail clearly. | Download and upload temporary URLs through one and nested scopes; signatures validate; anonymous served build fails clearly; unserved and base disks unchanged. | -| 13 | Document that Hypervel Filesystem::hash defaults to xxh128 while Laravel defaults to md5, including the porting implication and explicit-algorithm escape hatch. | Documentation review plus existing/default and explicit hash algorithm tests. | -| 14 | No production change; the dependency evidence and Swoole conclusion are recorded above. | No framework regression test is needed for a dependency invariant. Keep the existing handshake/close lifecycle tests. | -| 15 | Compile a conservative validation batch-eligibility plan. Batch a database presence rule only when no preceding executable rule can reject or transform the value; allow only proven metadata flags before it. Delegate all other attributes to normal ordered validation. Put any new rule-category metadata beside Validator's existing implicit/dependent/size rule categories and share it with the compiler rather than creating another free-floating list. | integer/regex/bail/custom-rule/subclass failures never query invalid raw data; order matches Laravel; eligible exists/unique rules still issue one batch query; PostgreSQL invalid input produces validation failure, not QueryException; compiler and delegated paths consume one category definition. | -| 16 | Treat precomputed presence results as an optimization, not semantic authority. On each precomputed miss or ambiguous multi-count, delegate that probe to the original presence verifier so the database collation/coercion decides; a group-level “all fetched values occurred in the input” check is insufficient when differently-cased inputs coexist. Memoize delegated results only within the validation execution. This is a data-integrity defect for unique rules: a bytewise miss can falsely pass a duplicate that the database collation considers equal. | MySQL/MariaDB and PostgreSQL cases for case folding, trailing spaces, numeric coercion, exists and unique; unique:users,email rejects a differently-cased duplicate under case-insensitive collation; two differently-cased inputs in one batch; exact hit stays query-free; fallback issues one delegated query per distinct missed/ambiguous value, with worst case equal to the unbatched path. | -| 17 | Pre-evaluate an exclusion rule only when it is the first executable rule, ignoring only metadata flags. Otherwise let the ordinary rule loop preserve Laravel order and bail semantics. | integer before exclude_if still fails integer; exclusion-first omits data; bail and dependent exclusion variants; delegated and compiled paths agree. | -| 18 | Substitute conditional-field wildcards using the validator's explicit wildcard captures for the current attribute, not every numeric path segment. | Numeric literal segments, one and multiple wildcards, mismatched wildcard counts, nested arrays, and escaped-dot fields. | -| 19 | Reset numericRules to its default before every inline validation check, matching the delegated path. | Numeric then string-valued checks in both orders; wildcard attributes; message selection remains isolated per attribute. | -| 20 | Remove AttributePlan::$required, $hasImplicitRule, the stored unused size mode, and the duplicated implicit-rule list/method. Keep one compiler source of truth and behavior-oriented tests. | Compiler output contains only consumed fields; implicit, required, nullable, and size-dependent behavior remains covered without reflection-pinning dead fields. | - -### Mail, notifications, gRPC, collections, and support - -| ID | Proposed implementation | Required tests | -|---:|---|---| -| 21 | Widen Mailable metadata values and storage shapes to int\|string\|null, matching Envelope. Cast consistently only where a downstream header API requires a string. | Integer and string metadata through send, render, and assertion helpers; null/absent metadata; strict-types regression. | -| 22 | Add only the missing `mail` driver to MailManager's existing poolable transport list. Its default `sendmail -bs` transport then follows the same borrow/isolation path already used by the explicit `sendmail` driver; do not introduce a second transport wrapper or command-mode abstraction. | `mail` is proxied by default and can accept explicit pool options; its interactive stream is reused only within one borrowed transport and concurrent sends cannot share a borrow; existing `sendmail` modes and fingerprints remain unchanged. | -| 23 | In hasEnvelopeAttachment, call attachments only when the mailable defines it; otherwise use an empty list. | Envelope-only mailable, mailable that also defines attachments, attachment match/no-match, and no method fatal. | -| 24 | Use one CoroutineContext path in every execution mode. Store under one package key a `WeakMap` keyed by transport object identity; this avoids `spl_object_id` reuse, isolates instances, lets dead transports disappear, and relies on CoroutineContext's existing non-coroutine fallback instead of branching on coroutine presence. Preserve all messages and flush semantics within one execution. | Sibling requests and separate transport instances cannot see each other's messages; flush is local; destroyed transports disappear from the non-coroutine weak map; many completed contexts leave no worker accumulation; non-coroutine test usage remains deterministic. | -| 25 | Make ChannelManager's Laravel-style deliverVia and locale setters lifecycle-aware: before `Application::isBooted()` they update worker baselines; after boot during an execution they update context overrides. Do not add a package-specific boot predicate. | Provider boot defaults are inherited by later request/job coroutines; request overrides do not affect siblings or the next execution; explicit notification locale still wins; flush resets both layers; shared porting-guide entry documents the semantic. | -| 26 | Keep AnonymousNotifiable::getKey returning null for Laravel fake/assertion parity, but make BroadcastNotificationCreated throw a descriptive exception when no explicit broadcast route exists instead of constructing a trailing-dot private channel. The audit's upstream comparison was wrong—Laravel also defines getKey—but the silent malformed-channel behavior remains a defect. | Anonymous broadcast without route fails loudly; explicit broadcast route works; normal model notifiable fallback unchanged; getKey remains null. | -| 27 | Check pre-transport deadlines before registering pending work and outside the native connection-error catch. Fail only the call with deadline status; retain the healthy connection. Keep actual mid-write native failures connection-fatal. | Deadline expires between scheduling and send; connection remains usable by a later call; expired write path classification; genuine write failure still terminates the connection. | -| 28 | Reject raw Swoole options that override first-class TLS ownership: ssl_verify_peer, ssl_cafile, ssl_cert_file, ssl_key_file, ssl_passphrase, and ssl_host_name. Continue allowing unrelated native options. | Every owned key conflicts clearly; first-class TLS values cannot be bypassed; unrelated ssl/native settings remain accepted; plaintext configuration unaffected. | -| 29 | Replace FILTER_VALIDATE_DOMAIN with an explicit resolvable service-name grammar that accepts underscores while rejecting whitespace, empty/malformed labels, invalid IP literals, and malformed ports. | Docker/Kubernetes-style underscore names; DNS names and IPv4/IPv6; invalid labels, whitespace, bracket, and port cases. | -| 30 | Add symfony/polyfill-php86 as a direct collections dependency because SortDirection is used by that split package. | Package metadata assertion and a standalone collections install/autoload smoke test without database. | -| 31 | Cast the single-item Arr::join result to string, matching the multi-item path and native return type. | One integer, float, stringable object, string, and multi-item list. | -| 32 | Use first() when guessing a resource collection class instead of reading items[0]. | keyBy, filtered/gapped keys, ordinary list, empty collection failure, and paginator/resource conversion. Finding 88 closes with these tests. | -| 33 | Preserve Number::useLocale/useCurrency as lifecycle-aware Laravel-compatible setters: use the current `Application::isBooted()` from Container when available, update static worker defaults before boot completes or in standalone use without an Application, and set context overrides after boot during an execution. Implement withLocale/withCurrency as explicit scoped context operations with reliable restoration. | Provider boot locale/currency inherited by requests; sibling overrides isolated; nested withLocale/withCurrency restore after success and exception; no-application CLI baseline; flush resets static and context state; shared porting-guide entry documents the semantic. | -| 34 | Port current Laravel's array-capable multibyte Str::substrReplace implementation, including array offset/length/replacement behavior and key preservation. Correct the scalar negative-length calculation as part of the port: the current Str::substrReplace('Hello', 'X', 2, -1) produces HeXello instead of HeXo. | Scalar parity including the explicit negative-length example; arrays with scalar and array replacements; offset/length arrays; associative keys; negative offsets and lengths; multibyte strings; mismatched replacement lengths. | -| 35 | For built-in UUID/ULID codecs, identify binary values by the unambiguous 16-byte storage length and validate textual 36/26-byte forms separately. Leave the generic public BinaryCodec heuristic available to custom codecs. Runtime sampling confirmed that roughly one in sixteen thousand random v4-shaped UUID payloads can be valid UTF-8 and NUL-free, so this is ordinary data loss at scale rather than a purely theoretical collision. | Deterministic valid-UTF-8, NUL-free 16-byte UUID/ULID payloads round-trip through casts and database bindings; a fixed previously misclassified v4 payload; textual forms; invalid lengths; custom codec behavior unchanged. | - -### Scout, permission, Sentry, Telescope, and foundation - -| ID | Proposed implementation | Required tests | -|---:|---|---| -| 36 | Render finite numeric filter values with Algolia's documented numeric comparison syntax, including equality as `field = 42`; the current facet form `field:42` is wrong for typed numeric attributes. Render strings as escaped facet filters and booleans with boolean facet syntax. Apply the same typed formatter to where, whereIn, and whereNotIn. Reject NaN and infinities. | Exact outgoing filter expressions for int, float, numeric-looking string, ordinary string, bool, backed enum, negative values, lists, escaping, NaN, and infinities. | -| 37 | Replace one Swoole defer per Scout job with one execution-local FIFO queue and one owner/drainer defer. Never coalesce operations whose ordering changes semantics. | Save then delete stays FIFO and cannot resurrect; delete then save; multiple models; one registered defer; exception handling leaves deterministic remaining work and cleans context. | -| 38 | Defer the FIFO only while an HTTP RequestContext is active. Without one—including console, seeders, and queue jobs—execute each non-queued Scout operation immediately in Laravel order; there is no response-latency benefit to retention there. Do not invent a queue threshold. | HTTP save/delete drains FIFO at execution end; long console/seeder/queue-job loops retain no deferred collections and perform operations incrementally; import behavior unchanged; network operation count is unchanged. | -| 39 | For Typesense take pagination, keep one fixed engine-valid per_page for every requested page so page offsets remain stable, collect until the requested take is covered or results end, then truncate the final collection. At most one page may be over-fetched. | take across one/multiple pages has no duplicates or gaps; fixed outgoing per_page; exact truncation; short page and found exhaustion; at most one excess page. | -| 40 | Remove the silent Typesense per-page clamp. Validate the documented Typesense maximum before sending and throw a descriptive InvalidArgumentException when callers request more than the engine supports, so paginator metadata can never disagree with the query. | Exact-limit pagination; over-limit failure before network call; page/current/total metadata for valid values; simple and length-aware paths. | -| 41 | Restrict the primary-key integer fast path to models whose Scout key name equals their Eloquent primary key name. Within that path, use the primary key name, type, and qualified column consistently, and validate decimal strings against PHP_INT_MAX without casting first. Models with a custom Scout key use the normal search path; there is no getScoutKeyType API from which to infer a safe custom-key optimization. The audit's claim that upstream consistently used getKeyName was wrong, but the mixed identities are still semantically broken. | PostgreSQL overflow string produces no integer-cast query error; max boundary; ordinary integer-primary-key fast path; custom Scout key name bypasses the primary-key optimization; string/UUID primary key; result identity mapping. | -| 42 | Assign the callback-transformed raw result before mapping models and before computing total/hasMore. Make rawResult, models, and paginator metadata describe the same payload. | Callback changes hits and count; length-aware and simple pagination; raw result exposure and mapped models agree; unchanged callback path. | -| 43 | Centralize permission cache settlement on the mutation's actual connection. Outside a transaction, invalidate immediately. Inside one, clear current execution hydration, mark affected catalog/model keys dirty so reads bypass shared and execution memos, and invalidate shared keys only after commit; rollback only clears dirty/runtime state. Serialize assignment-cache misses and their exact committed invalidations with per-key locks; hits remain lock-free. Keep nested rollback bookkeeping scoped to the actual transaction record. Do not replace exact invalidation with the existing partition-global assignment token: shared keys already include that token, and bumping it per model mutation would invalidate every model in the partition and orphan old entries until TTL. Validate lock support only when this cache is enabled. | The mutating transaction sees its own grant/revoke without publishing it; concurrent executions keep pre-commit state; commit invalidates even after a forced stale re-prime; rollback never publishes uncommitted rows and restores fresh reads; fill-vs-commit barriers; nested savepoint rollback; catalog, role, permission, team, partition, and pivot paths; cache hits perform no lock or new remote operation; cold-fill lock cost is measured. | -| 44 | Add an execution-local memo for hydrated direct permissions, parallel to the via-role memo. Memoize only non-loaded hydration and clear it from every model permission/assignment invalidation path. | One hasPermissionTo call hydrates direct permissions once; repeated calls query once; invalidation, team change, partition change, and sibling execution isolation. | -| 45 | Apply role team filtering in the shared fallback/filter path so every catalog bypass retains the current-team boundary, including both a requested role-class mismatch and the complex-parameter/catalog fallback. Include only global roles and roles for the current team. | Same role name on another team never matches through either fallback; class-mismatch and complex-parameter cases; current-team and global roles match; null-team behavior follows the explicit policy from 47. | -| 46 | Use the cached catalog when the configured role class is compatible with the requested base class. For a genuinely incompatible valid class, memoize one class/partition catalog per execution and then apply current-team filtering. | Compatible subclass uses the shared cache; incompatible class makes one query per execution; invalidation clears it; team and cache partition separation. | -| 47 | Make team-scoped writes fail early with a named package exception when no team is selected, matching the shipped non-nullable assignment pivots. Keep null-team reads fail-closed as an empty relation so checks cannot leak assignments from another team; document that nullable team IDs apply to global Role records, not subject-assignment pivots. | assignRole, givePermissionTo, sync operations, queued model writes, and direct pivot paths with no team; configured team success; null-team reads return no subject assignments and never broaden the query; exception and docs name the missing team context. | -| 48 | Port both Sentry Monolog handlers fully to Monolog 3 LogRecord APIs. Use isHandling and Level comparisons; create modified records with LogRecord::with; make doWrite consume LogRecord and strip exception data from a local context copy. | Single and batch records; highest handled level; context enrichment survives; exception context is handled once; below-threshold records drop; immutable original record remains valid. | -| 49 | Give Hub an explicit mutable baseline Scope and consult `Application::isBooted()` through the Application contract. `configureScope` before boot completes mutates the baseline; after boot during an execution it mutates the cloned context scope. Do not add a Sentry-specific boot predicate or infer lifecycle from coroutine presence. | AppServiceProvider/Sentry provider boot tags and user data appear in later requests; sibling request mutations are isolated; nested push/pop scopes; queue/console/HTTP execution entry; flush resets baseline and context; shared porting-guide entry documents the semantic. | -| 50 | Null-guard internal trace frames in sentry:test before reading filename or line. | Trace containing internal/null-file frame; ordinary frame formatting; command still sends the diagnostic event. | -| 51 | Make commandFinished flush event buffers without waiting for every transport-wide in-flight request. Reserve the blocking transport drain for worker shutdown. | Command completion does not wait for an unrelated request send; buffered telemetry is scheduled/flushed; worker shutdown still drains; transport failures remain reported. | -| 52 | Add one small JSON-normalization helper and use it only at Telescope call sites that currently encode/decode arbitrary observed values and can throw. Apply the native invalid-UTF-8/partial-output flags and return a valid normalized value; do not build a general recursive graph serializer. | Each actual event/job/request/view/dump/model throw site with invalid UTF-8 and non-finite values; valid payloads remain byte/shape compatible; watcher dispatch does not escape because of normalization. | -| 53 | Invoke every afterStoring hook with foreach/each rather than Collection::every, which treats void as false. Preserve the chosen exception policy explicitly. | First hook returns void and all later hooks run; false return does not stop hooks; throwing hook behavior and reporting are pinned. | -| 54 | If a dump should be shown but Telescope is not recording the current execution, delegate to the previous dump handler. Only consume output when Telescope actually records it. | Dashboard/always-record flag in ignored execution delegates; active recording stores once; disabled dump watcher delegates; no duplicate output. | -| 55 | Keep the cheap process-lifetime memory_get_peak_usage value and Laravel-compatible memory payload key, but label it in the UI/docs as the worker memory peak. Do not add request-delta bookkeeping: concurrent coroutine allocations make that number neither a request peak nor reliably attributable. | Payload key remains compatible; UI/docs say worker peak; value remains monotonic process telemetry; no request context state or extra measurements are introduced. | -| 56 | Cast non-array Stringable and UriInterface inputs to string before the uri helper's route/string dispatch. Preserve the array route form. | Stringable, league URI, plain string, route array, and invalid unsupported object. | - -### Sanctum, Fortify, Socialite, JWT, Passkeys, Inertia, Saloon, and nested set - -| ID | Proposed implementation | Required tests | -|---:|---|---| -| 57 | Store token/tokenable cache values in a small package-owned presence envelope so the normal Cache Repository contract can distinguish absent from cached-null without using its internal raw/sentinel API. Define an explicit cache-store capability for truly atomic `add()` and implement it only on the existing model-safe stores whose operation is atomic; after the existing model/serializer safety validation, require the selected Sanctum store itself to expose that capability. This rejects multi-layer Stack, memoized, Storage, and fallback get/put publication without widening the fail-closed custom-store policy. Retain the existing boot-time positive-TTL validation. On a cold fill, read, query, atomically add a positive or negative envelope for the configured TTL, and if add loses, reread and honor the winner. After commit, create/update overwrites with authoritative fresh state (reload tokenable after an ownership-changing update), while delete/bulk delete overwrites both keys with negative envelopes; an in-flight stale add therefore cannot resurrect revoked state. Remove `updateLastUsedAt`'s duplicate snapshot re-put and let the updated lifecycle publish once. Do not require locks or invent a shorter tombstone TTL. | Deterministic token-miss-vs-delete, tokenable-fill-vs-delete, last-used-vs-delete, create-vs-negative-fill, and update-vs-stale-fill barriers; once revocation returns, later auth is denied; transaction commit/rollback and bulk delete; add loser honors the authoritative winner; Redis, database, file, and Swoole stores pass both safety and atomic-add validation; Stack, memoized, Storage, fallback-only, and unsupported custom stores fail at boot; zero/negative TTL retains its existing boot failure; cache hits perform no new operation; mutation and cold-fill operation counts. | -| 58 | Add a dedicated per-guard actingAs context override. Guard::user and hasUser consult it before bearer-token keyed caches; forgetUser clears the override and ordinary token caches. | actingAs wins even with Authorization bearer header; per-guard and sibling isolation; forget restores normal token auth; ordinary tokens unchanged. | -| 59 | Port and register the named two-factor limiter normally, without a conditional-existence guard, and key it by the challenged login/account identity stored in session. Application providers boot later and RateLimiter::for naturally overwrites the package default. If the login identity is unexpectedly absent, fall back to the current session ID and then IP rather than putting every malformed flow in one null bucket. Make the default pipeline use the named limiter. | Same account across IPs shares the cap; different accounts on one IP do not; missing login identity isolates by session/IP; later application registration overrides naturally; successful challenge behavior. | -| 60 | Return an empty recovery-code list for null or empty encrypted storage. Treat it as normal challenge failure while allowing malformed encrypted ciphertext to remain a loud configuration/data error. | Two-factor disabled between login and challenge; empty/null codes; locked refetch; malformed encryption still throws the correct exception. | -| 61 | When force-enabling two-factor rotates the secret/recovery codes, clear two_factor_confirmed_at in the same write whenever confirmation is enabled. Non-forced enable remains unchanged. | Confirmed user forced rotation requires confirmation again; non-force preserves state; confirmation feature disabled; write atomicity. | -| 62 | Store the raw redirect Closure\|string in the cached provider baseline and lazily resolve it at most once per execution against the current request URL service. Absolute literals can stay on the direct path. Do not evaluate request-derived closures while building a worker-cached provider. | Two hosts/tenants through one cached provider get their own callback URLs; closure/url resolution runs once per execution; concurrent requests cannot poison each other; absolute redirect has no context overhead. | -| 63 | Centralize absolute/relative/closure redirect normalization in getRedirectUrl. setConfig and redirectUrl store raw execution overrides, invalidate that execution's resolved value, and pass through the same resolver. | Relative and absolute redirects from base config, setConfig, redirectUrl, and closures; current request origin; override invalidation; sibling isolation. | -| 64 | Pass the configured id_token_alg, defaulting to RS256, as Firebase JWK::parseKeySet's default for keys that omit alg. Let the JWT library validate algorithms/signatures; do not add a second maintained whitelist. | JWKS key without alg; key with alg; configured override; library rejection of unsupported/mismatched algorithms; key rotation/cache refresh; signature and claim failures. | -| 65 | Keep the single modern `x` OAuth 2 driver, but remove the `services.x-oauth-2` configuration fallback retained by Socialite for historical compatibility. Read only canonical `services.x`; do not add an `x-oauth-2` alias or restore Twitter/OAuth 1. Keep the package docs and shared porting guide consistent with that one modern name and configuration path. | `x` resolves only from `services.x`; legacy `services.x-oauth-2` alone does not configure it; `x-oauth-2` and `twitter` driver names remain unsupported; package, user, and porting documentation describe only the one modern OAuth 2 driver and key. | -| 66 | Always route logout with a token through manager invalidation. Let the existing blacklist-disabled exception abort before logout events or context clearing. | Blacklist disabled throws and the authenticated user remains; no Logout event; enabled blacklist invalidates and clears normally. | -| 67 | After successful invalidate, clear cached payload and user entries for that token while retaining the token identity so the next decode observes the blacklist. | Same-execution user/getPayload/check fail after invalidation; grace-period behavior; other tokens unaffected. | -| 68 | jwt:secret writes only JWT_SECRET. Never create or overwrite JWT_ALGO; the shipped config already supplies HS256 when no environment override exists. | Missing algorithm uses config default; existing RS/ES algorithm survives force and confirmation paths; secret replacement behavior. | -| 69 | Scope the delete-route passkey lookup to the authenticated user's passkeys, preserving the configured model's route-key behavior. Foreign and nonexistent identifiers both become the same ModelNotFound response before DeletePasskey runs. | Own, foreign, and nonexistent identifiers with identical 404 response for the latter two; custom route key/model and morph owner; unauthenticated flow. | -| 70 | Apply the package's passkeys throttle middleware to destroy exactly as to the other passkey mutation routes, and omit it when configured null. | Default throttle on destroy; custom limiter; null disables; route middleware snapshot. | -| 71 | Split Inertia callable resolution by prop contract. Deferred, Optional, and Once props invoke their wrapped callable through the container unconditionally; Merge, Always, Scroll, and generic data preserve callable-looking arrays/strings as data unless they are callable objects intended for invocation. | Callable arrays, static method strings, closures, invokable objects, and callable-looking data across every prop wrapper; each true callable invoked exactly once. | -| 72 | Apply partial-reload inclusion before traversing dot-notation props. Excluded entries are dropped without resolving their leaf; included descendants resolve only the intermediate structure required to reach them and then flow through normal prop resolution. | Excluded dot closure never runs; included closure runs once; multiple included descendants; parent/child collisions; Arrayable/lazy intermediate values. | -| 73 | Add X-Inertia: true to reload requests and make AssertableInertia parse a JSON page for Inertia responses while retaining view parsing for non-Inertia responses. | Real middleware reload, 409 location response, 303 redirect normalization, already-loaded page, partial headers, and legacy view response. | -| 74 | Widen the inertia helper's accepted value to include ProvidesInertiaProperties, matching Factory, and update imports/docs. | Provider object, array, closure, and property context conversion. | -| 75 | Make replaceHeaders replacement case-insensitive and have all authenticators replace Authorization rather than append it. | Default auth then refresh; mixed casing; exactly one newest Authorization value; unrelated headers retained. | -| 76 | Track pages yielded independently from page numbers. Reset the counter for each iteration/pool, increment after a yielded response, and enforce maxPages from that counter for startPage 0 or 1. | Start pages 0 and 1; max 0, 1, and N; iterator and pool request identical pages/counts; repeated iteration resets. | -| 77 | Correct Saloon docs: replaceHeaders replaces matching header names and retains unrelated headers. | Documentation review plus 75's behavior tests. | -| 78 | Remove the freshness-revision optimization and make execution of each pending structural action inside the model save/delete/restore lifecycle the sole database-preflight owner. Relationship fluent setup (`appendOrPrependTo`, `beforeOrAfterNode`, and related helpers) keeps its current/upstream in-memory assertions for immediate feedback but performs no reload; helpers such as `saveAsRoot` that must decide from persisted position defer that decision to the lifecycle boundary. At action execution, action-specific preparation reloads every participating existing node's mutation identity/structural columns once from the write connection, re-asserts against that fresh snapshot, and mutates from it. Remove duplicate `ensureMutationIdentityIsLoaded`/`refreshNodeForMove` preflights, while retaining post-write refreshes where the public API promises an updated target/model and the result is not derivable. This restores the upstream nested-set action shape and improves merge fidelity instead of retaining a Hypervel-only correctness-critical revision cache. A retry after rollback cannot reuse coordinates from the rolled-back attempt. Remove `NodeFreshness` and all revision/rollback bookkeeping; retain structural-identity comparison in a focused `NodeIdentity` helper. | Closure deadlock retry after final structural write; commit-time serialization failure retry; sequential/nested savepoint rollback; successful commit; invalid relationship setup still throws immediately without querying; `saveAsRoot` decides from its action-boundary snapshot; each participating existing node performs exactly one pre-mutation authoritative row read per executed action; fresh action assertions catch changes after fluent setup; query logging distinguishes and preserves required post-write refreshes; new nodes and ordinary reads add none. | -| 79 | Use the same stateless action-boundary preparation as 78. An execution-local clock misses child writes, while a worker-local clock still cannot observe another process and therefore cannot prove a retained model current. Reloading means coordinates always come from the write connection immediately before the package's mutation logic, and no retained in-memory freshness state is trusted across coroutines, rollbacks, or retries. It does not serialize concurrent writers or close the race between that read and the write; continue requiring application-level serialization to the same table/scope exactly as documented. Bulk `fixTree`/`fixSubtree`/`rebuildTree`/`rebuildSubtree` paths retain their one wholesale snapshot and internal direct-assignment/save path and never add per-node preflights. | Parent-child and sibling-coroutine sequential mutation interleavings; a model passed through a child; retry uses the latest write-connection snapshot; an intentionally unserialized concurrent-writer test/documentation fixture demonstrates that serialization remains required; bulk repair/rebuild query counts stay O(1) reads rather than N+1; no freshness state remains; benchmark the one preflight read within representative serialized structural writes. | -| 80 | Share relation precondition logic: unsaved parents produce an empty relation, but persisted models missing bounds, parent, or scope columns throw LogicException. Apply it to lazy constraints and eager model preparation for descendants, ancestors, and siblings. | Partial select lazy relation, eager load, and destructive relation call all fail loudly; each missing structural field; unsaved model remains empty. | - -### Database, image, collections duplicate, pagination, JSON Schema, and API client - -| ID | Proposed implementation | Required tests | -|---:|---|---| -| 81 | Override MySqlConnection::resetForPool, call the parent reset, and clear lastInsertId. | Two consecutive pool borrow windows; first inserts and sees ID, second sees null before inserting; discard/reset paths. | -| 82 | Apply incrementEach's strict string-column and numeric-amount validation to decrementEach before constructing raw SQL. | Malicious SQL fragment and nonnumeric amount rejected before query; non-string/associative shape failures; valid ints, floats, and numeric strings update correctly. | -| 83 | Add one package-internal MIME buffer helper with a lazily initialized worker-static finfo and use it from Image and InterventionDriver. finfo::buffer is stateless and non-yielding, so no DI service, coroutine state, lock, reset method, or AfterEachTestSubscriber registration is needed; retaining the handle for the worker lifetime is the intended ownership. | Both call sites report identical MIME across repeated processing; invalid data behavior; ordinary image tests remain independent without a reset seam. | -| 84 | For HEIC/HEIF, if the selected driver cannot decode dimensions, throw ImageException with the driver exception as previous. Never fall back to the known-inaccurate native reader. | Driver success; driver failure with previous exception; native reader is not called for HEIC; ordinary image fallback unchanged. | -| 85 | Replace dimension and effect clamps with consistent InvalidArgumentException validation matching the declared ranges. Invalid dimensions are caller input errors, not image-decoding failures. | Zero/negative width and height for cover/contain/crop/resize/scale; blur/sharpen below 0 and above 100; exact valid boundaries. | -| 86 | Detect unsupported driver names before delegating. Let exceptions from registered custom creators propagate unchanged and validate that the creator returned a Driver with a descriptive result-type error. | Unknown driver message; custom creator InvalidArgumentException preserved; wrong return type; valid extension. | -| 87 | Document that decode/transform/encode are synchronous CPU work that block the worker event loop, and direct heavy conversions to task workers or queued jobs. Do not invent unsupported numeric thresholds. | Documentation review against image driver behavior and worker/task terminology. | -| 88 | No second implementation; close with 32. | 32's tests. | -| 89 | Amend pagination README so current_page_url is documented for both simple and length-aware paginator JSON. | Documentation review plus existing serialization snapshots for both paginator types. | -| 90 | Remove CursorPaginator's duplicate hasMore property and retain the abstract declaration. | Reflection confirms one declaration; cursor pagination behavior unchanged. | -| 91 | Pass this paginator's pageName to resolveCurrentPage when direct construction receives a null page. | Custom p resolves p rather than page; default page; explicit page bypasses resolver. | -| 92 | Remove pagination's hard runtime requirements on database and http. Keep them in require-dev and add Composer suggests only if the optional model/resource transformations need explanation. | Standalone pagination Composer install/autoload; metadata has no cycle; model/pivot/resource instanceof paths still work when optional packages are installed. | -| 93 | No change; see disposition above. | Preserve nullable-path and state-flush behavior. | -| 94 | Match Laravel's Contracts\JsonSchema\JsonSchema shape: keep the public interface, remove its concrete native return types/imports, and express the concrete types only in fully-qualified PHPDoc. JsonSchemaTypeFactory continues to implement it with covariant native returns. Do not add a binding; neither Laravel nor Hypervel documents container injection for this factory. | Standalone hypervel/contracts install/autoload and third-party contract implementation without json-schema installed; concrete factory covariance; static JsonSchema entry API; reflection confirms the Laravel-compatible method surface remains present. | -| 95 | Serialize schema types with ordered instanceof dispatch from most specific to base type. Keep extension open rather than marking all type classes final. | Subclasses of every supported type serialize; most-specific subclass route wins; unsupported unrelated type still fails. | -| 96 | Implement the honest representable subset of JSON Schema 2020-12 ref siblings: add absent assertions, accept identical assertions, and allow annotation siblings such as title/description/default to overlay. If the target and sibling provide different values for the same assertion keyword, throw a descriptive unsupported-conjunction exception rather than silently weakening either schema or building partial allOf machinery. Document this lasting conjunction boundary in the json-schema package docs. | Added/identical assertions; annotation overlay; differing scalar/required/properties assertions fail loudly; the prior outer-wins weakening is impossible; reference cycles and missing refs; documentation matches the supported subset. | -| 97 | Resolve anyOf branches once, retain the resolved tuples, and pass them to nullable/general normalization without a second ref traversal or node count. | Nullable union near MAX_NODES; an injected counting resolver double proves one lookup/count pass without production instrumentation; ordinary union and cycle behavior. | -| 98 | Correct only the stdClass branch error so it names the unsupported schema fragment. Keep Serializer::$ignore as a protected static extension point; converting it to a constant is style churn and could break subclasses. | Exact meaningful error for invalid property/branch stdClass; valid properties map; subclass customization of ignored keywords remains possible. | -| 99 | Make asJson and asForm call ensureStructuredMutationAllowed. GET and HEAD structured conversion throws consistently even when parsed query data is present; raw withBody remains the explicit body API. Update API-client docs. | Empty and query-bearing GET/HEAD; POST/PUT conversion; raw body on GET/HEAD; no duplicated query body. | -| 100 | Move the API bridge out of beforeSending and register it as the first internal HTTP middleware when the underlying PendingRequest is created, after the base prepared-body tracker but before every user Guzzle middleware. It constructs the normal Http Request wrapper (including structured data/attributes), runs API request middleware once, stores activeRequest, and forwards its PSR request. This is one bridge layer replacing the existing callback, so legitimate cache/circuit-breaker short-circuits still have context without extra steady-state middleware. | Cache/circuit-breaker middleware short-circuits after the API bridge without a null fatal; API request/response middleware run once; exact ordering against user and beforeSending middleware; body/data/attribute mutations; normal, retry, and async-disabled sends; defensive invariant error. | -| 101 | Make both mutable builders—`Hypervel\ApiClient\PendingRequest` and `Hypervel\Http\Client\PendingRequest`—implement SelfBuilding with public static `newInstance(): static` returning a fresh instance. Both have container-resolvable all-default constructors and otherwise become unsafe auto-singletons; Saloon's connector-required request is not affected. | Two container resolutions of each class are distinct; concurrent HTTP builders do not share options, middleware, callbacks, cookies, promises, or fakes; concurrent API builders do not share middleware, client, context, or active request; newInstance and documented factory/facade paths. | -| 102 | Add ApiResource::__set that throws LogicException and align property and array mutation messages with their syntax. | Property assignment and unset; offset set/unset; no dynamic shadow; reads and toArray/toJson remain identical. | - -### Database worker safety, watcher, Reverb, Wayfinder, and Tinker - -| ID | Proposed implementation | Required tests | -|---:|---|---| -| 103 | When missing-attribute prevention is disabled, keep offsetExists on the direct path with no context work. When enabled, wrap getAttribute in an execution-local suppression depth consulted only by the exceptional missing-attribute branch, restoring in finally and supporting nesting. This is execution state, not a boot/default setter, so `Application::isBooted()` is intentionally irrelevant. | Two forced interleavings cannot disable strict mode for a sibling or permanently; lazy relation yield; nested isset; custom missing-attribute callback; ordinary non-strict benchmark stays on the direct path. | -| 104 | Resolve the root seeder with Container::build, matching Seeder::resolve and its fresh-instance convention. | Two programmatic db:seed runs receive distinct root objects; nested seeder remains fresh; container dependencies inject correctly. | -| 105 | In Option::parseGlob, truncate the non-wildcard prefix to the last slash before the first wildcard. Map app/Foo*.php to app and .env* to dot; preserve absolute/relative matching. | Wildcard after filename prefix, root dotfile glob, wildcard directly after slash, nested braces/classes, all watcher drivers receive an existing base. | -| 106 | Measure monotonic elapsed time from the previous scan's start and add exactly one second for filesystem timestamp granularity. Round GNU find's fractional-minute value up to its representable 0.01-minute unit; use ceiling whole minutes for non-GNU find. The existing mtime map deduplicates the intentional overlap. | Default interval plus a deliberately slow scan has no blind tail; sub-300ms interval never becomes -0.00; exact fractional rounding; non-GNU ceiling; no duplicate events. | -| 107 | Read server.settings.daemonize with a false default because application server settings may validly replace the framework settings map. | Minimal server.php with only worker_num; explicit true rejects; explicit false starts. | -| 108 | Check signalProcess's boolean return and report false as failure; retain Throwable handling for extensions/test doubles. | Native false return logs failure; true does not; throwing override logs; absent PID does nothing. | -| 109 | No change; see disposition above. | Retain full-content detection tests, including same-size/coarse-timestamp rewrites. | -| 110 | Enumerate hidden files in ScanFileDriver because WatchPath matching already accepts them and other drivers report them. | Hidden files and hidden directories under a watched target; matching exclusion pattern; parity with find/fswatch. | -| 111 | Prune modification-map entries whose recorded mtime is older than the current lookback/deduplication horizon. They can no longer suppress a future find result, so remove them without a file_exists syscall per historical path. | Repeated unique create/change/delete cycles keep the map bounded; no per-history stat calls; recreated path emits; overlap dedupe remains correct. | -| 112 | Add a manual-only `reverb:clear-state` command for crash recovery. Require all Reverb nodes using the selected Redis connection/prefix to be stopped, scan only RedisSharedState's `reverb:{*}:*` namespace across the selected connection's cluster nodes, and delete in bounded UNLINK batches (DEL fallback where unavailable). Provide `--dry-run`; otherwise require interactive confirmation or `--force`. Document the stop/clear/start runbook and explicitly exclude webhook buffer keys. Never schedule it, invoke it at boot, or wire it into automatic recovery; do not add leases, heartbeats, per-node aggregation, or hot-path Redis work without operational evidence. | Dry-run reports without deletion; confirmation/force behavior; only shared-state counters/locks/smoothing keys are removed; webhook and unrelated Redis data survive; multi-batch and cluster-wide scanning; stopped-nodes safety warning; command registration does not add scheduled/boot execution; docs runbook. | -| 113 | Render Wayfinder @see with docblock_method when explicitly supplied, otherwise original_method, never the allocated TypeScript identifier. | Reserved PHP method renamed in TS; collision suffix; invokable; named and controller files; IDE target string. | -| 114 | Strip the :parameters suffix from gathered middleware before class reflection for URL::defaults extraction. | Parameterized class middleware, alias-resolved middleware, unparameterized middleware, and absent class. | -| 115 | Detect duplicate route names before generation and fail with a generator exception listing both conflicting routes. Do not emit ambiguous overloads because Laravel route caching also treats duplicate names as invalid. | Two different URIs with one name; identical duplicate; error identifies methods/URIs; ordinary grouped namespaces compile. | -| 116 | After optional parameter replacement and trailing-slash normalization, floor an empty generated URL to slash before query-string concatenation. Avoid lookbehind-dependent JavaScript. | Root optional parameter omitted and present; root with query; nested optional route; generated runtime and typecheck. | -| 117 | Parse PHP integer literals by explicit prefix, including 0o/0O with octdec, and reuse the helper for signed literals. | 0o/0O, legacy octal, hex, binary, decimal, separators, positive/negative signs. | -| 118 | Remove the `command.tinker` binding and register TinkerCommand::class directly so AsCommand populates the lazy command map. Laravel's string key exists to support its deferred provider, which Hypervel intentionally omits; retaining an alias would carry that mechanism's residue into the canonical container surface. Record the deliberate omission at the port's natural source/test location and in the package's concise `Differences From Laravel` note. | Artisan list does not instantiate the command; `tinker` resolves lazily and runs; class resolution remains a worker singleton through Hypervel auto-singletoning; `command.tinker` is not bound; documentation identifies class resolution as the modern surface. | -| 119 | No change; see disposition above. | Preserve optional-list omission test. | -| 120 | Remove `App\Nova` from Tinker's shipped `dont_alias` default. Nova is a Laravel-only package with no Hypervel equivalent, so this is framework-specific integration residue rather than a canonical API. Preserve the configurable exclusion list for applications that need their own entries. | Shipped default is empty; published config is empty; an application-supplied `dont_alias` list still prevents aliases normally. | -| 121 | Replace the bound-closure appends read with Model::getAppends and drop the unused catch variable. | Empty and multiple appends; hidden/visible prefixes; accessor evaluation; exception probing unchanged. | -| 122 | Save and restore/delete COMPOSER_VENDOR_DIR in teardown with try/finally, and use ParallelTesting::tempDir with unconditional teardown cleanup for the coroutine scratch file. | Environment restored after success and failure; scratch removed after failed assertion; later TestStateRegistrars test sees original environment. | - -### Redis, cache, testing, facade documenter, prompts, Testbench, queue, auth, HTTP client, and Horizon - -| ID | Proposed implementation | Required tests | -|---:|---|---| -| 123 | No production change; retain the resolved disposition above. | Serializer-enabled get returns array, object, int, float, string, and null mapping without TypeError. | -| 124 | Pass the original Throwable as previous when wrapping Sentinel and Redis Cluster connection creation, and correct message punctuation. | Previous exception identity/type/trace for both paths; message; successful creation. | -| 125 | Widen RedisStore increment/decrement and their operation execute methods to bool\|int, matching Store. Pass false through without a TypeError. | Native false response; positive/negative integer result; repository/stack/tagged callers; exceptions still propagate. | -| 126 | Make TagMode::fromConfig throw InvalidArgumentException for any value other than all or any. Include the accepted values in the message. | Both valid modes; typo, case error, and empty value fail while resolving the store; benchmark command path. | -| 127 | Sort expected and actual policy-result maps by normalized model key before strict comparison. | Input models in reverse/random order vs unordered query; string and integer keys; real value mismatch still fails diagnostically. | -| 128 | Widen assertJsonValidationErrors and assertOnlyJsonValidationErrors to array\|string\|null and retain Laravel's clean “No validation errors were provided” assertion for null. The outer assertInvalid methods must never fail at PHP argument type checking. | JSON assertInvalid/assertOnlyInvalid with null yields an assertion diagnostic, not TypeError; explicit keys/messages; session behavior unchanged. | -| 129 | No change; see disposition above. | Preserve reset/subscriber lifecycle tests. | -| 130 | Read `view.compiled` untyped and use it only when `is_string($path) && $path !== ''`; otherwise return null and let required consumers' existing typed access remain the failure boundary. Remove ParallelTestingServiceProvider's explicit same-class singleton binding; Hypervel auto-singletons the unbound concrete class, so repeated class resolution retains the intended worker instance without copying Laravel's container implementation detail. | Null/missing/empty/non-string compiled path skips worker suffixing; valid string gets the suffix; the class is not explicitly bound before resolution and repeated class resolution returns the same auto-singleton instance. | -| 131 | Add per-run memoization for class imports by source file/namespace, parsed docblocks by exact string, facade method-name sets by class, and ReflectionMethodDecorator source ReflectionClass. Hoist immutable PHPDoc parser objects. Do not add production counters or test-only observability seams. | Generated output remains byte-identical; cache keys separate namespaces/files/docblocks and cannot cross-contaminate results; a focused before/after benchmark outside timing-sensitive CI demonstrates the repeated-input speedup. | -| 132 | Recursively walk traits-of-traits and parent traits when matching a method's source file for import resolution. Guard cycles/duplicates by trait name. | Public method declared in a nested trait resolves that trait's imports; parent trait chain; class import with same short name does not win. | -| 133 | Map supported PHPStan scalar refinements to runtime base types and preserve generic values: list/non-empty-list becomes array, non-empty-array preserves K,V, string refinements become string, signed integer refinements become int. Keep template resolution before unknown fallback. | Each listed pseudo-type; nested union/intersection/generic; template named like a class; generated facade PHPDoc contains no unintended mixed. | -| 134 | Catch only ReflectionException around getPrototype, reject unknown CLI flags with nonzero exit, send warnings/exceptions to STDERR, and add ext-tokenizer to facade-documenter's package requirements. | Malformed prototype docblock surfaces; typoed flag fails without writing; stdout remains generated output only; metadata assertion and standalone script smoke test. | -| 135 | Enforce the integer contract in NumberPrompt validation with a signed-decimal integer grammar and range checks before conversion. Accept signs and leading zeros; reject fractions, exponents, surrounding whitespace, and overflow rather than truncating/coercing them. Use the same parser for validation, returned value, min/max, step, and arrows. | Signed integers, zero, leading zeros, PHP int boundaries; fractions, exponents, whitespace, and overflow rejected; min/max, step, transform, and validation ordering. | -| 136 | After every arrow-key mutation, set cursorPosition to mb_strlen(typedValue) rather than adjusting by one. | Empty to multi-digit negative min/max; -1 to 0; 9 to 10; clamped step; subsequent typed character lands at the end. | -| 137 | Clamp the arrow padding passed to str_repeat to at least zero; DrawsBoxes will then size the box to the already terminal-bounded body without a second truncation pass. In cancel rendering, distinguish an empty string from the valid string 0 explicitly. | Wide terminal and long pasted value cannot throw or overflow the terminal bound; narrow terminal; cancel with 0 shows 0; empty cancel shows placeholder. | -| 138 | Replace newline parsing with one fixed length-prefixed binary frame for every logger message, including ordinary lines. Encode a compact type plus raw payload length and payload; parse with a cursor over the receive buffer and compact only consumed prefixes, avoiding repeated whole-buffer slicing. The producer and renderer are one internal deployment unit, so do not add protocol negotiation/version machinery. This preserves arbitrary bytes/newlines with less CPU and wire expansion than base64. Do not add production instrumentation for tests. | Multiline/blank/binary content for line, success, warning, error, label, sublabel, reset, partial, and commit; fragmented/coalesced socket reads; malformed length/type handling; exact bytes written for representative frames; in-process/process parity; a large-input benchmark outside timing-sensitive CI confirms linear scaling. | -| 139 | Send only each partial delta over IPC and feed it to one incremental partial-layout buffer shared by process and in-process loggers. Retain the existing visible log ring plus bounded unfinished wrapping/ANSI state, not the entire already-discarded prefix; split long words with the existing width semantics. This uses the Task's output limit rather than a new arbitrary cap. Clear on commit/stable reset. Transport, memory, and layout work must scale linearly with input. | Wire bytes and layout work grow linearly; long uncommitted streams stay bounded by the existing viewport and wrapping state; Unicode, ANSI, long words, multiline wrapping, commit/reset, and process/in-process final output parity. | -| 140 | Replace bool plus usleep animation ownership with a stop Channel and WaitGroup. The loop waits with the animation interval, stop wakes it immediately, and the caller joins the animation coroutine before erase/final render/terminal restore. Share the primitive between Spinner and Task. | Suspended in-flight render completes before final erase; callback success/failure; render failure; immediate completion; no extra frame after settlement; cursor restored. | -| 141 | Do not install Progress's pcntl exit handler inside a coroutine. Leave process signal ownership to the framework/runtime there; retain and restore the standalone non-coroutine handler. | Coroutine start leaves handler/async-signal state unchanged and cannot surface Swoole ExitException; standalone SIGINT handler setup/restore; manual and map settlement. | -| 142 | Track the state to return to when showing a transient revert error. The next key restores search rather than hardcoding active; ordinary validation errors still return to active. | CTRL_U in DataTable search, then typing/navigation remains search; CTRL_U in ordinary prompt; validation error recovery. | -| 143 | Add CTRL_P and CTRL_N to MultiSearchPrompt's up/down navigation arms. | Both bindings move highlight and do not clear the search match cache; boundary behavior matches sibling prompts. | -| 144 | Lazily cache only the expensive search-invariant natural column metrics on DataTablePrompt for the duration of one run; derive terminal-width fitting in O(columns) on each frame and clear the metrics when a new run starts. Keep public headers/rows mutable and provide explicit layout invalidation only for deliberate mutation during an active run, rather than narrowing the API or hashing all cells every keystroke. | Thousands-row prompt scans/sorts cells once across keystrokes and terminal resizes; resize refits without rescanning; mutation before or between runs recomputes automatically; in-run mutation plus explicit invalidation; no worker/static retention. | -| 145 | Fix all five valid subissues: eraseLines moves up one line per iteration; append NumberPrompt's transform to the number helper signature without reordering existing parameters; flushState forgets output and validation context keys; scrollbar last-character replacement is Unicode-aware; max error says at most. Do not expand FormBuilder. | Exact terminal escape sequence for counts 0/1/3; positional/named number calls and transform success/exception; context keys removed without whole-context flush; multibyte trailing character; max boundary and wording. | -| 146 | Use array_key_exists for nullable namespace/core-binding caches. Detect the actual workbench directory relative to package_path so root-monorepo mappings such as src/testbench/workbench/app and standalone workbench/app both match composer PSR-4 entries. | Negative lookup reads composer.json once; custom monorepo namespace detected; standalone layout; force refresh; nullable core binding cached. | -| 147 | Check base_path(app/Models/User.php) for the skeleton fallback. | Workbench model precedence; skeleton App\Models\User; no model; AUTH_MODEL override. | -| 148 | Move TerminatingConsole::flush from Symfony command configure/constructor into the beginning of handle. | Artisan list/help/completion construction preserves cleanup callbacks; executing sync intentionally flushes; custom persistent skeleton cleanup. | -| 149 | Import Testbench's existing LoadEnvironmentVariables subclass in CreatesApplication so TestCase's environment path gets the bundled .env.testbench fallback. Keep Testbench Foundation\\Application's explicit Foundation loader: its separately configured application resolver intentionally loads only the selected application's environment before overlaying its env array. | TestCase skeleton with/without .env uses the correct loader/fallback; custom environment file; Foundation Application retains its base-loader-plus-array sequence. | -| 150 | Resolve the foundation config directory from ReflectionClass(Hypervel\Foundation\Application)::getFileName rather than package_path's monorepo layout. Validate that the directory exists before use. | Split installed package layout; components monorepo root package; missing/corrupt install fails clearly; attribute loads framework config. | -| 151 | Always load the Testbench YAML/config state and Swoole-testing flag, then if BASE_PATH is already defined skip only source resolution, stale purge, runtime copy, and duplicate shutdown registration. Use BASE_PATH as the idempotence/ownership guard rather than a second static flag. | Two direct calls copy/register once while required config state is available; helper-before-TestCase; bin and ParallelRunner paths; pre-defined BASE_PATH; runtime clone is not overlaid. | -| 152 | Reuse the existing TEST_TOKEN sanitization grammar in runtime-copy paths; filter integer/numeric environment-map keys that cannot be represented by the declared subprocess string-key map; remove the unreachable migration-directory guard; null-coalesce teardown's migration cache; and remove duplicate migration-option resolution. Do not add a first-caller descriptor/assertion or expand ConfigContract for unsupported bootstrap implementations. | Traversal/punctuation/nonnumeric TEST_TOKEN; numeric env keys are omitted without TypeError and valid env survives; migration resolution only when enabled; teardown after failed setup preserves the original failure; one option resolution; supported bootstrap paths remain unchanged. | -| 153 | No change; see disposition above. | Preserve immediate-kill, non-drain, timeout event, and idempotency documentation coverage. | -| 154 | Remove only the dead SQL Server lock branch and document ARGV[2] as the Redis migration batch limit. Keep coroutine-hooked usleep and the shutdown-only 1ms poll; do not add Support\\Sleep or a Concurrent completion primitive. | Database grammar set excludes sqlsrv; Lua argument documentation/review; existing Worker sleep override and shutdown drain tests remain green. | -| 155 | Make DatabaseUserProvider and DatabaseTokenRepository accept ConnectionInterface\|ConnectionResolverInterface in the existing first constructor position and add the optional connection name only after the existing parameters. Framework factories pass the resolver/name and each operation resolves the current execution's connection; direct Laravel-style construction with a ConnectionInterface remains valid for non-pooled use and test doubles. Add concise constructor PHPDoc warning that a directly supplied connection must not outlive its execution. `getConnection()` returns the current resolved ConnectionInterface. No porting-guide entry is needed because the Laravel constructor form remains valid. | Existing Laravel-compatible positional construction and named arguments/mocks remain valid; framework construction does not borrow; provider/broker survives creator coroutine teardown; two concurrent requests use distinct leases; transaction state does not cross; configured and scoped-default connection selection; every repository/provider method; constructor docs name the direct-connection lifetime constraint. | -| 156 | Type ResponseSequence::$emptyResponse as Closure\|PromiseInterface\|null and initialize it to null. | whenEmpty closure receives no assignment TypeError and returns per call; promise path; default failure and dontFailWhenEmpty. | -| 157 | Initialize PendingRequest::$promise to null. | getPromise before send returns null; async send assigns promise; clone/new request does not inherit an uninitialized state. | -| 158 | Retain requestsReusableClient and getReusableClient for Laravel subclass compatibility. Make Response::cookies return nullable CookieJar to match its property and Laravel's actual null behavior for unpopulated/recorded responses. | Recorded/assertSent response cookies returns null; normal populated response returns jar; protected method reflection/subclass smoke test; async client behavior unchanged. | -| 159 | Use raw PHP_BINARY in HorizonRestartStrategy's array-form Symfony Process command. Keep PhpBinary::path only for shell command-string substitution. | Real array command starts a child executable; path containing spaces; environment argument; existing shell command strings remain correctly quoted; remove the mock-only blind spot. | -| 160 | Port the current laravel/vonage-notification-channel package into a Hypervel split package, then make Horizon's existing routeSmsNotificationsTo API effective by adding the missing route in SendNotification and channel selection in LongWaitDetected::via, adapted to the current `vonage` channel, `toVonage`, and VonageMessage. Do not copy Horizon upstream's obsolete `nexmo` name or introduce a second/deprecated driver. `Vonage\Client` caches service objects whose `APIResource` mutates `lastRequest`/`lastResponse` around yielding HTTP calls, so framework-created channels build a fresh SDK Client per send while sharing only normalized immutable configuration and Hypervel's coroutine-safe PSR-18 transport. Add per-execution memoization only later if measurement proves construction material. Keep the Vonage facade non-caching and preserve direct construction with a supplied Client and per-message `usingClient` overrides. Add the package README, canonical notification docs, Horizon docs, `HorizonServiceProvider.stub`, and Horizon Boost notification reference using `vonage` only. | Ported package provider/config, route resolution, message construction, channel send/failure, facade, direct constructor, and per-message override; deterministic concurrent sends cannot exchange SDK request/response state; repeated sends get distinct SDK clients but reuse the safe HTTP transport; Horizon's two consumers use `vonage`; absent number adds no channel; mail/Slack composition; public routeSmsNotificationsTo remains unchanged; all docs/stubs/Boost references contain no `nexmo` surface. | - -## Commit and dependency structure - -Use package-sized commits that remain reviewable and bisectable. The following order avoids building fixes on obsolete primitives: - -1. Worker-default and execution-state primitives: 25, 33, 49, 103. -2. Pool/resource ownership: 10-12, 22, 27-29, 81, 155. -3. Transaction/cache consistency: 43-47 and 57, using their package-specific lock and atomic-publication designs. -4. Nested-set authoritative mutation preparation and relation guards: 78-80. -5. Validation and data representation: 15-21, 30-35, 82, 94-98. -6. Search and request pipelines: 36-42, 62-77, 99-102. -7. Observability: 48-55. -8. Reverb recovery command and runbook: 112, with no runtime state-model change. -9. Queue cleanup: only the two valid parts of 154; 153 deliberately stays unchanged. -10. Vonage notification channel port followed by Horizon wiring: 160. -11. Remaining package-local correctness work by package, followed by performance/docs/cleanup. - -Do not combine unrelated packages merely because their findings have the same severity. - -## Verification protocol - -For every changed test file: - -1. From the components repository root, run that exact test file immediately with `./vendor/bin/phpunit --no-progress path/to/Test.php`. -2. For deterministic concurrency tests, run the exact file repeatedly and under the parallel runner where supported. -3. Run the complete affected package test directory after its individual files pass. -4. Run integration suites for every affected external system: MySQL/MariaDB and PostgreSQL for validation/permission/database semantics; Redis for Sanctum/Reverb/cache; filesystem cloud adapter tests where credentials/fixtures are provided. - -Additional required checks: - -- Wayfinder: run npm test, npm run test:cached, and npm run typecheck from src/wayfinder. -- Split-package metadata changes: run the package metadata tests and a clean standalone Composer install/autoload smoke test. -- Facade documenter: run lint and write modes against representative facades, confirm diagnostics use STDERR, then run static analysis. -- Documentation changes: verify every claimed API and difference against the final source; update package README and src/docs together where both describe it. -- Runtime races: use barriers/channels to force the bad ordering. A test that merely starts two coroutines without controlling their interleaving is insufficient. - -After all package work is complete: - -1. Run `composer fix` once as the repository checkpoint. It owns formatting, static analysis, the parallel suite, Testbench package tests, and dogfood tests; do not duplicate those full checks immediately beforehand. -2. Inspect git diff and git status; ensure generated fixtures, temporary files, environment changes, node output, and unrelated user changes are absent. - -## Completion criteria - -- Every audit ID has the disposition recorded above. -- Findings 4, 9, 14, 93, 109, 119, 129, and 153 remain unchanged for the stated reasons. -- Finding 88 is closed by 32 rather than implemented twice. -- Finding 123 remains fixed and regression-covered. -- All 150 open unique remediation entries have production, test, documentation, or metadata changes as specified. -- No worker-global mutable state is introduced without an explicit boot-only contract and reset path. -- No pooled borrowed resource escapes its operation scope. -- No cache fill can republish state after a completed revocation/invalidation. -- All exact-file, package, integration, split-package, TypeScript, static-analysis, lint, dogfood, and final composer fix checks pass. From c720123a98890fd2183cb34e0b39a3e4347141dd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:02:16 +0000 Subject: [PATCH 15/22] fix(validation): preserve non-scalar rule ordering Treat any attribute containing non-scalar parsed parameters as one delegated unit so compiler prepasses cannot invoke user objects before Laravel rule order reaches them. Cache scalar-parameter metadata on delegated checks, retain meta rules for the subclass execution path, remove redundant compiler guards, and cover nested arrays, objects, resources, nulls, and base/subclass compilation boundaries. --- src/validation/src/DelegatedCheck.php | 6 ++ src/validation/src/RuleCompiler.php | 48 +++++------- .../Validation/ValidationRuleCompilerTest.php | 78 ++++++++++++++++--- 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/src/validation/src/DelegatedCheck.php b/src/validation/src/DelegatedCheck.php index 48fc3a525..a99270400 100644 --- a/src/validation/src/DelegatedCheck.php +++ b/src/validation/src/DelegatedCheck.php @@ -12,6 +12,8 @@ */ final readonly class DelegatedCheck { + public bool $parametersAreScalar; + /** * @param string $ruleName Parsed rule name (e.g., 'Exists', 'Required'). Empty for * Rule objects dispatched via validateUsingCustomRule(). @@ -25,6 +27,10 @@ public function __construct( public array $parameters, public mixed $originalRule = null, ) { + $this->parametersAreScalar = array_all( + $parameters, + static fn (mixed $parameter): bool => is_scalar($parameter), + ); } /** diff --git a/src/validation/src/RuleCompiler.php b/src/validation/src/RuleCompiler.php index db4ed21dd..6eeab97d2 100644 --- a/src/validation/src/RuleCompiler.php +++ b/src/validation/src/RuleCompiler.php @@ -6,7 +6,6 @@ use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Validation\Enums\CheckType; -use Stringable; /** * Compile pipe-string or array rules into an AttributePlan. @@ -33,6 +32,13 @@ public static function compile(array $rules, array $numericRules): AttributePlan static fn (mixed $rule): array => ValidationRuleParser::parse($rule), $rules, ); + + foreach ($parsedRules as [, $parameters]) { + if (array_any($parameters, static fn (mixed $parameter): bool => ! is_scalar($parameter))) { + return self::compileAllDelegated($rules); + } + } + $context = self::collectContext($parsedRules, $numericRules); foreach ($rules as $index => $rule) { @@ -45,9 +51,9 @@ public static function compile(array $rules, array $numericRules): AttributePlan /** * Compile all rules as DelegatedCheck (no inlining). * - * Used for Validator subclasses which may override validate*() methods. - * Retains the same meta-flag resolution so the execution loop's - * attribute-level logic still works. + * Used for Validator subclasses and attributes with non-scalar rule + * parameters. Retains meta flags for attribute-level gating while keeping + * every declared rule available to delegated execution. * * @param list $rules As produced by ValidationRuleParser::explode() */ @@ -84,12 +90,8 @@ private static function collectContext(array $parsedRules, array $numericRules): $numeric = true; } - if ($dateFormat === null && $parsedName === 'DateFormat') { - $format = $parsedParameters[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') { @@ -131,8 +133,8 @@ private static function compileRule(mixed $rule, array $parsedRule, AttributePla return; } - // 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; @@ -162,9 +164,8 @@ private static function compileRule(mixed $rule, array $parsedRule, AttributePla /** * 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. */ private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): void { @@ -185,15 +186,10 @@ private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): 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; } $plan->checks[] = new DelegatedCheck( @@ -280,9 +276,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, @@ -292,9 +286,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, diff --git a/tests/Validation/ValidationRuleCompilerTest.php b/tests/Validation/ValidationRuleCompilerTest.php index 362ba15c2..791bbc087 100644 --- a/tests/Validation/ValidationRuleCompilerTest.php +++ b/tests/Validation/ValidationRuleCompilerTest.php @@ -336,27 +336,74 @@ public function testDateWithSiblingFormatBaked() $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 = $this->compile([['date_format', 123], 'after:124']); - $stringablePlan = $this->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 = $this->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() @@ -481,13 +528,24 @@ public function testEmptyArrayRuleSkipped() 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->assertCount(3, $plan->checks); + $this->assertTrue($plan->nullable); + $this->assertTrue($plan->bail); + $this->assertTrue($plan->sometimes); + $this->assertCount(6, $plan->checks); } public function testMultipleOfLiteralInlines() From 1ca69023d31029d54a493abeafbeff6390b29ba7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:02:31 +0000 Subject: [PATCH 16/22] fix(validation): keep presence facts binding-exact Preserve native scalar candidates until the database connection binds them, and leave Stringable and date/time values on the ordinary verifier path so preflight cannot run user code or bypass grammar-owned conversion. Key precomputed and memoized facts by complete query shape and PDO binding identity, reject unsupported lookup conditions without coercion, and add regression coverage for mixed numeric identities, unsupported candidates, and execution-local fallback facts. --- src/validation/src/BatchDatabaseChecker.php | 5 +- .../src/PrecomputedPresenceVerifier.php | 13 +---- .../ValidationBatchDatabaseCheckerTest.php | 52 +++++++++++++++++-- ...idationPrecomputedPresenceVerifierTest.php | 49 ++++++++++++++++- 4 files changed, 98 insertions(+), 21 deletions(-) diff --git a/src/validation/src/BatchDatabaseChecker.php b/src/validation/src/BatchDatabaseChecker.php index 2d0834f7a..4dbc0f3b7 100644 --- a/src/validation/src/BatchDatabaseChecker.php +++ b/src/validation/src/BatchDatabaseChecker.php @@ -4,8 +4,6 @@ namespace Hypervel\Validation; -use Stringable; - /** * Query wildcard database-presence candidates in groups. * @@ -200,8 +198,7 @@ private static function normalizeCandidates(array $values): array continue 2; } - $rawValue = $item instanceof Stringable ? substr($bindingKey, 1) : $item; - $candidateValues[$bindingKey] ??= $rawValue; + $candidateValues[$bindingKey] ??= $item; } foreach ($candidateValues as $bindingKey => $rawValue) { diff --git a/src/validation/src/PrecomputedPresenceVerifier.php b/src/validation/src/PrecomputedPresenceVerifier.php index e9968ca4b..3eddf6a31 100644 --- a/src/validation/src/PrecomputedPresenceVerifier.php +++ b/src/validation/src/PrecomputedPresenceVerifier.php @@ -5,8 +5,6 @@ namespace Hypervel\Validation; use Closure; -use DateTimeInterface; -use Stringable; /** * Return database-proven presence facts from batched queries. @@ -52,7 +50,7 @@ public static function lookupKey( foreach ($extra as $key => $value) { if ($value instanceof Closure - || (! is_scalar($value) && $value !== null && ! $value instanceof Stringable) + || (! is_scalar($value) && $value !== null) ) { return null; } @@ -230,14 +228,7 @@ public static function bindingKey(mixed $value): ?string */ public static function normalizeValue(mixed $value): ?string { - // Connection::prepareBindings() formats dates through the query grammar. - if ($value instanceof DateTimeInterface) { - return null; - } - - if ((! is_string($value) && ! is_int($value) && ! is_float($value)) - && ! $value instanceof Stringable - ) { + if (! is_string($value) && ! is_int($value) && ! is_float($value)) { return null; } diff --git a/tests/Validation/ValidationBatchDatabaseCheckerTest.php b/tests/Validation/ValidationBatchDatabaseCheckerTest.php index 38eb58ddd..a5e94c410 100644 --- a/tests/Validation/ValidationBatchDatabaseCheckerTest.php +++ b/tests/Validation/ValidationBatchDatabaseCheckerTest.php @@ -43,7 +43,7 @@ public function testUsesTheCompleteQueryShapeAndRetainsRawSqlBindings(): void $this->assertSame(0, $verifier->getCount('users', 'id', '1', 'ignored', 'uuid', ['status' => 'active'])); } - public function testNormalizesOneDimensionalArraysAndCastsStringableValuesOnce(): void + public function testNormalizesNativeArraysAndDelegatesStringableValuesWithoutCasting(): void { $casts = 0; $stringable = new class($casts) implements Stringable { @@ -62,14 +62,56 @@ public function __toString(): string $presenceVerifier = m::mock(DatabasePresenceVerifier::class); $presenceVerifier->shouldReceive('getExistingValues') ->once() - ->with('users', 'id', [1, 2, '3'], null, null, null, []) - ->andReturn(['1', '2', '3']); + ->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(1, $casts); - $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 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 diff --git a/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php b/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php index 0beea8d81..9b63fc114 100644 --- a/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php +++ b/tests/Validation/ValidationPrecomputedPresenceVerifierTest.php @@ -38,8 +38,24 @@ public function testLookupKeyUsesEffectiveExclusionAndNormalizedConditions(): vo public function testLookupKeyRejectsConditionsThatCannotBeReplayed(): void { + $casts = 0; + $stringable = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + public function __toString(): string + { + ++$this->casts; + + 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 testScalarFactsUseTheirDatabaseProvenState(): void @@ -61,13 +77,24 @@ public function testScalarFactsUseTheirDatabaseProvenState(): void public function testFactsRequireTheSubmittedBindingIdentity(): void { - $stringable = new class implements Stringable { + $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') + ->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, []) @@ -88,6 +115,7 @@ public function __toString(): string $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')); } @@ -206,9 +234,26 @@ public function testMultiCountUsesKnownPresentOnlyForASoleDistinctInput(): void public function testMultiCountDelegatesUnknownUnsupportedAndCrossChunkFactsAsAWhole(): void { + $casts = 0; + $stringable = new class($casts) implements Stringable { + public function __construct(private int &$casts) + { + } + + 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( @@ -221,6 +266,8 @@ public function testMultiCountDelegatesUnknownUnsupportedAndCrossChunkFactsAsAWh $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'])); } From 861016307d0315bc15e214760c18f489d4c889bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:02:52 +0000 Subject: [PATCH 17/22] fix(validation): preserve ordered compiled execution Keep the exact-base optimized loop aligned with Laravel rule order while routing subclasses through their protected extension hooks, using cleaned keys only for messages and internal placeholder keys for rules, data, and exclusions. Activate pre-evaluated exclusions only when execution reaches them, use an execution-local set to avoid quadratic base-validator scans, defer stale or non-scalar outcomes, and retain Laravel behavior for absent sometimes attributes and later exclusions. Make presence planning fail closed around unresolved parents and unsafe prefixes, skip non-presence wildcard plans before reading their values, and cover global early-stop, escaped-dot fields, mutators, repeated passes, database bindings, and base/subclass parity. --- src/validation/src/PlanExecutor.php | 67 +++-- src/validation/src/Validator.php | 162 ++++++++---- ...ValidationBatchDatabaseCheckerTestCase.php | 144 +++++++++++ .../ValidationCompiledExecutionTest.php | 232 ++++++++++++++++++ .../ValidationPreEvaluatedExclusionsTest.php | 230 ++++++++++++++++- 5 files changed, 766 insertions(+), 69 deletions(-) diff --git a/src/validation/src/PlanExecutor.php b/src/validation/src/PlanExecutor.php index bea2051a4..9ea8d0b94 100644 --- a/src/validation/src/PlanExecutor.php +++ b/src/validation/src/PlanExecutor.php @@ -18,7 +18,7 @@ * * ## Architecture overview * - * Hypervel's validation uses a compiled single-path execution model: + * Hypervel's validation uses compiled execution with a delegated subclass path: * * 1. Rules are compiled into AttributePlans by RuleCompiler — each rule * becomes either an InlineCheck (fast, match-dispatched) or a DelegatedCheck @@ -29,16 +29,16 @@ * wildcard database-presence candidates are queried in ordered groups. * Unknown values remain on the ordinary verifier path. * - * 3. This trait executes the compiled plans through a single loop. InlineChecks - * use simplified gating (all are non-implicit by design) and match-dispatch. - * DelegatedChecks call validateAttribute() directly — no duplication of - * upstream logic, zero maintenance burden for delegated rules. + * 3. The exact Validator executes optimized plans through a branch-free loop. + * Subclasses use a Laravel-shaped loop over delegated checks so their + * validation and stopping hooks remain authoritative. * * ## Maintenance notes * - * - Adding a new inline-eligible rule requires changes in 3 places: - * CheckType (enum case + ruleName), RuleCompiler::tryInline(), and - * executeInline() below. Forgetting executeInline() fails PHPStan. + * - Adding a new inline-eligible rule requires review in 4 places: CheckType + * (enum case + ruleName), RuleCompiler::tryInline(), executeInline() below, + * and canPreflightInline(). Omitting preflight support is correctness-safe + * but prevents presence batching across that rule. * - DelegatedChecks require zero changes — they call validate*() directly. * - executeInline() arms must match the exact behavior of their corresponding * validate*() methods in ValidatesAttributes. When an upstream method @@ -53,26 +53,33 @@ trait PlanExecutor /** * Execute all compiled plans against the validation data. * - * This is the ONLY execution path — every rule (inline or delegated) flows - * through this loop. Per-check fresh reads of $value and $exists match - * validateAttribute()'s per-rule getValue() call. + * Per-check fresh reads of $value and $exists match validateAttribute()'s + * per-rule getValue() call. * * @param array $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; - $cleanedAttribute = $this->replacePlaceholderInString($attribute); + + if ($this->shouldBeExcluded($attribute)) { + $this->removeAttribute($attribute); + continue; + } if ($this->stopOnFirstFailure && $this->messages->isNotEmpty()) { 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 @@ -124,6 +131,38 @@ protected function executeCompiledPlans(array $compiledPlans): void } } + /** + * 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. */ diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index d876759a1..723ee241f 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -55,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. */ @@ -127,16 +137,6 @@ class Validator implements ValidatorContract */ protected ?PresenceVerifierInterface $originalPresenceVerifier = null; - /** - * 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. - * - * @var array - */ - protected array $preExcludedAttributes = []; - /** * All of the registered "after" callbacks. */ @@ -444,24 +444,15 @@ 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 = []; if (static::class === self::class) { - $unresolvedExclusionAttributes = $this->preEvaluateExclusions( - ! $this->compiledPlansUseDataMutatingRules(), - ); - - if ($this->preExcludedAttributes !== []) { - $this->compiledPlans = array_filter( - $this->compiledPlans, - fn (string $attribute): bool => ! $this->isPreExcludedOrDescendant($attribute), - ARRAY_FILTER_USE_KEY, - ); - } + [$preExcludedAttributes, $unresolvedExclusionAttributes] = $this->preEvaluateExclusions(); $activeVerifier = $this->presenceVerifier; // A failed speculative PostgreSQL query aborts the caller's transaction @@ -470,16 +461,24 @@ public function passes(): bool && $activeVerifier::class === DatabasePresenceVerifier::class && ! $this->stopOnFirstFailure ) { - $this->maybeBatchDatabaseChecks($activeVerifier, $unresolvedExclusionAttributes); + $this->maybeBatchDatabaseChecks( + $activeVerifier, + $preExcludedAttributes, + $unresolvedExclusionAttributes, + ); } } 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); } } @@ -588,19 +587,32 @@ protected function compiledPlansUseDataMutatingRules(): bool /** * Pre-evaluate safe first-position exclusion checks. * - * This pass records resolved exclusions on the validator instance. - * - * @return array attributes whose exclusion outcome remains unresolved + * @return array{0: array, 1: array} * @phpstan-impure */ - protected function preEvaluateExclusions(bool $canPreEvaluate): array + protected function preEvaluateExclusions(): array { + $preExcludedAttributes = []; $unresolvedAttributes = []; + $possibleExclusionPrefixes = []; + $dataMayDiffer = false; + $canPreEvaluate = null; /** @var array $exclusionOutcomes */ $exclusionOutcomes = []; foreach ($this->compiledPlans as $attribute => $plan) { $attribute = (string) $attribute; + + 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; + } + $firstExclusionIndex = null; $hasLaterExclusion = false; @@ -618,14 +630,29 @@ protected function preEvaluateExclusions(bool $canPreEvaluate): array continue; } - if (! $canPreEvaluate || $firstExclusionIndex !== 0) { + if ($dataMayDiffer || $firstExclusionIndex !== 0) { $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; continue; } /** @var DelegatedCheck $firstCheck */ $firstCheck = $plan->checks[0]; + if (! $firstCheck->parametersAreScalar) { + $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + continue; + } + + $canPreEvaluate ??= ! $this->compiledPlansUseDataMutatingRules(); + + if (! $canPreEvaluate) { + $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; + continue; + } + try { $parameters = $firstCheck->parameters; $explicitKeys = []; @@ -662,29 +689,34 @@ protected function preEvaluateExclusions(bool $canPreEvaluate): array } } catch (InvalidArgumentException|ValueError) { $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; continue; } if (! $passes) { - $this->preExcludedAttributes[$attribute] = true; + $preExcludedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; continue; } if ($hasLaterExclusion) { $unresolvedAttributes[$attribute] = true; + $possibleExclusionPrefixes[$attribute] = true; } } - return $unresolvedAttributes; + return [$preExcludedAttributes, $unresolvedAttributes]; } /** * Batch safe wildcard database-presence candidates by query shape. * + * @param array $preExcludedAttributes * @param array $unresolvedExclusionAttributes */ protected function maybeBatchDatabaseChecks( DatabasePresenceVerifier $presenceVerifier, + array $preExcludedAttributes, array $unresolvedExclusionAttributes, ): void { if ($this->implicitAttributes === []) { @@ -707,10 +739,29 @@ protected function maybeBatchDatabaseChecks( continue; } + $firstPresenceIndex = 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 (($plan->sometimes && ! $exists) - || $this->hasAttributeAncestorInSet($attribute, $unresolvedExclusionAttributes) + if (isset($preExcludedAttributes[$attribute]) + || ($preExcludedAttributes !== [] + && $this->hasAttributeAncestorInSet($attribute, $preExcludedAttributes)) + || ($plan->sometimes && ! $exists) + || ($unresolvedExclusionAttributes !== [] + && $this->hasAttributeAncestorInSet($attribute, $unresolvedExclusionAttributes)) ) { continue; } @@ -723,7 +774,9 @@ protected function maybeBatchDatabaseChecks( continue; } - foreach ($plan->checks as $index => $check) { + for ($index = $firstPresenceIndex, $checkCount = count($plan->checks); $index < $checkCount; ++$index) { + $check = $plan->checks[$index]; + if (! $check instanceof DelegatedCheck || ($check->ruleName !== 'Exists' && $check->ruleName !== 'Unique') ) { @@ -794,6 +847,10 @@ private function extractPresenceRuleMeta( DelegatedCheck $check, string $attribute, ): ?array { + if (! $check->parametersAreScalar) { + return null; + } + if (($check->originalRule instanceof Rules\Exists || $check->originalRule instanceof Rules\Unique) && $check->originalRule->queryCallbacks() !== [] ) { @@ -939,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 . '.') @@ -950,21 +1013,6 @@ protected function shouldBeExcluded(string $attribute): bool return false; } - /** - * Determine if the attribute or any of its ancestors was pre-excluded. - * - * Walks up the dot-separated segments checking each prefix against - * $preExcludedAttributes. O(depth) where depth is typically 2-4. - */ - private function isPreExcludedOrDescendant(string $attribute): bool - { - if (isset($this->preExcludedAttributes[$attribute])) { - return true; - } - - return $this->hasAttributeAncestorInSet($attribute, $this->preExcludedAttributes); - } - /** * Determine if any strict ancestor of an attribute belongs to a set. * @@ -1295,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; } /** @@ -1391,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; } @@ -1415,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/ValidationBatchDatabaseCheckerTestCase.php b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php index 66d5d41a9..dcd12fd32 100644 --- a/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php +++ b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php @@ -114,6 +114,59 @@ public function testBatchingActivatesEndToEndForStringFormExists(): void $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( @@ -912,6 +965,81 @@ public function testResolvedFirstExclusionKeepsPresenceChecksBatchable(): void )); } + 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( @@ -1337,3 +1465,19 @@ 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/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index f3f0d25cd..22ad15385 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -427,6 +427,186 @@ public function testSubclassWithOverriddenValidateStringIsNotBypassed() $this->assertFalse($v->passes()); } + 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( @@ -1011,6 +1191,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/ValidationPreEvaluatedExclusionsTest.php b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php index 6c6977801..2c101f0d3 100644 --- a/tests/Validation/ValidationPreEvaluatedExclusionsTest.php +++ b/tests/Validation/ValidationPreEvaluatedExclusionsTest.php @@ -13,6 +13,7 @@ use Hypervel\Translation\Translator; use Hypervel\Validation\Validator; use InvalidArgumentException; +use Stringable; class ValidationPreEvaluatedExclusionsTest extends TestCase { @@ -60,6 +61,36 @@ public function testExcludeIfKeepsAttributeWhenConditionNotMet(): void $this->assertArrayHasKey('publish_date', $v->validated()); } + 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( @@ -472,12 +503,207 @@ public function testPreExcludedParentExcludesDescendantAttributes(): void $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 { - return new Validator( + $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 + { + $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; + } +} From 7bc34f240e193d0fe896fd2ef9e273345f7d0abb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:03:03 +0000 Subject: [PATCH 18/22] perf(validation): benchmark production planner wiring Construct optimized and legacy validators through one helper that installs the same concrete database presence verifier used in production. This keeps the CPU scenarios query-free while ensuring optimized timings include the real no-presence planning gate, and adds deterministic fluent-rule and typeless-size workloads for the newly compiled paths. --- .../Console/BenchmarkValidationCommand.php | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/validation/src/Console/BenchmarkValidationCommand.php b/src/validation/src/Console/BenchmarkValidationCommand.php index 6f0223cc8..1971421a1 100644 --- a/src/validation/src/Console/BenchmarkValidationCommand.php +++ b/src/validation/src/Console/BenchmarkValidationCommand.php @@ -5,10 +5,13 @@ 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; @@ -81,7 +84,11 @@ public function handle(): int $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) { @@ -93,15 +100,33 @@ public function handle(): int RulePlanCache::flushState(); ValidationRuleParser::flushState(); - $optimizedPassed = (new Validator($translator, $data, $rules))->passes(); + $optimizedPassed = $this->makeValidator( + Validator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(); $optimizedMs = $this->benchmark( - fn () => (new Validator($translator, $data, $rules))->passes(), + fn () => $this->makeValidator( + Validator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(), $iterations, ); RulePlanCache::flushState(); ValidationRuleParser::flushState(); - $legacyPassed = (new LegacyValidator($translator, $data, $rules))->passes(); + $legacyPassed = $this->makeValidator( + LegacyValidator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(); if ($optimizedPassed !== $legacyPassed) { $this->error("Optimized and legacy validation disagree for scenario: {$scenario}."); @@ -110,7 +135,13 @@ public function handle(): int } $legacyMs = $this->benchmark( - fn () => (new LegacyValidator($translator, $data, $rules))->passes(), + fn () => $this->makeValidator( + LegacyValidator::class, + $translator, + $data, + $rules, + $presenceVerifier, + )->passes(), $iterations, ); @@ -159,6 +190,24 @@ private function benchmark(callable $callback, int $iterations): float : $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; + } + /** * Build the data and rules for a benchmark scenario. * From 8896a915b4bcd23f5cf2966d6c73a7a5d409216e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:03:20 +0000 Subject: [PATCH 19/22] docs(validation): finalize remediation design Bring the focused plan in line with the implemented parser, compiler, ordered preflight, exclusion, executor, presence-fact, database-matrix, and benchmark architecture. Record the post-implementation and final-review defects at their owning design boundaries, keep the exact database verification commands, and mark the fully verified acceptance checklist complete after whole-branch peer signoff. --- ...validation-audit-remediation-plan-codex.md | 196 +++++++++++++----- 1 file changed, 142 insertions(+), 54 deletions(-) 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 index b2d5be0d0..a60004fc8 100644 --- 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 @@ -1,6 +1,6 @@ # Validation audit remediation plan -Status: Implementation complete; final code review pending +Status: Complete Branch: `audit/validation-remediation` from `0.4` @@ -8,7 +8,7 @@ Scope: master-audit findings 15–20 plus validation defects exposed while revie ## 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, single compiled execution loop, inline predicates, exclusion prepass, and wildcard database-presence batching. Restore upstream-safe rule-object canonicalization so modern fluent rules benefit from the same compiled path instead of becoming a second, slower architecture. +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. @@ -30,6 +30,15 @@ The finished code must be the simplest design that is correct under Hypervel's l - **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 @@ -40,7 +49,7 @@ The defects are optimizer-boundary mistakes, not flaws in the overall refactor. 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 one real validation loop; delegated rules still call the established `validateAttribute()` path. +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. @@ -55,6 +64,7 @@ Local references were checked at: 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. @@ -84,6 +94,10 @@ These invariants govern every change in this slice: - 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. @@ -197,11 +211,22 @@ Tests: 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. @@ -248,12 +273,13 @@ Leave these eight cases unlisted: `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. -Add a fourth step to `CheckType`'s existing maintenance docblock: an inline case may be added to the preflight allowlist only after proving repeat evaluation is free of user callbacks, I/O, warnings, and reachable exceptions; omission is safe and only disables batching across that prefix. Do not add behavior or another rule-name registry to the enum. +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; @@ -328,18 +354,20 @@ Files: Rewrite the candidate half of `maybeBatchDatabaseChecks()` around the already filtered `compiledPlans`, not raw `$rules`: 1. Retain the current wildcard-only optimization boundary. -2. Skip plans whose `sometimes` flag is set when the concrete key is absent. -3. Apply the shared non-implicit and invalid-upload predicates once per attribute, before the check loop; neither depends on the current check or index. -4. Locate each `Exists` / `Unique` `DelegatedCheck` by its already parsed rule name. Walk only its preceding checks, in declaration order, before resolving presence metadata: +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. -5. 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. -6. 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. -7. 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. +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: @@ -369,6 +397,7 @@ Tests: - 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; @@ -471,9 +500,11 @@ Tests: #### 7.2 Query only normalizable candidates -Normalize each concrete candidate independently. Strings, integers, floats, ordinary `Stringable` values, and one-dimensional arrays containing only those types remain supported. Booleans, null, `DateTimeInterface`, and other unsupported candidates are skipped without declining safe siblings. Date/time objects must delegate because `Connection::prepareBindings()` formats them through the connection grammar rather than their `__toString()` method. Unsupported runtime probes must delegate before consulting any stored fact. +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; cast a supported `Stringable` once. `Connection::bindValues()` binds integers as `PDO::PARAM_INT` and the other supported values as `PDO::PARAM_STR`, so add one collision-free binding key with a positional prefix: +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 @@ -484,11 +515,11 @@ public static function bindingKey(mixed $value): ?string } ``` -Keep `normalizeValue()` as the shared string comparison form for candidates and fetched database values. The binding key delegates to it, so support checks and `Stringable` conversion remain in one place. Float, string, and a cast `Stringable` intentionally share the `s` form because PDO binds each as the same string representation. Prefixed keys also prevent PHP from silently converting numeric-string fact-map keys to integers. +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. Reuse that suffix as an ordinary `Stringable` value's representative so `__toString()` runs once. 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. Booleans and date/time objects are excluded before normalization, so no binding-conversion approximation is needed. +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. @@ -549,6 +580,8 @@ This small boolean is necessary; without it, database-equivalent exact represent 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; @@ -576,7 +609,7 @@ 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 rather than executable checks, 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`. When a first-position exclusion resolves non-excluding and the attribute survives the pre-excluded filter, let that attribute's presence-prefix walk skip the resolved exclusion. Exclusions marked unresolved by position, malformed parameters, or the mutation gate remain uncertain and keep ordinary per-value presence execution. +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()`: @@ -588,15 +621,34 @@ if ($keys = $this->getExplicitKeys($attribute)) { } ``` -After parameter normalization, call the corresponding existing `validateExclude*()` predicate without adding a failure; a false result means pre-exclude and a true result resolves non-excluding. This reuses `parseDependentRuleParameters()` and therefore handles boolean/null coercion and non-scalar values exactly like execution instead of maintaining the current manual approximation. The exact-base/no-used-mutator gate makes repeated access to `$this->data` safe. +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, so its inputs cannot change during its lifetime and it adds no plan, coroutine, or worker state. Keep calling `getValue($attribute)` on misses to match normal invocation. Do not cache exception deferrals, which would add a third state for a rare path, and do not add plan-identity metadata or a broader `getExplicitKeys()` pattern cache. +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 a plain set of potential parent exclusions for step 5. A first-position exclusion whose predicate returned false is pre-excluded; a true result is resolved non-excluding. An exclusion later in its plan, a malformed exclusion deferred after `InvalidArgumentException`, or any exclusion while the mutation gate is closed remains unresolved. Pass that local set into presence candidate building; do not store execution order or add a validator property. A candidate declines batching only when an unresolved exclusion attribute is a strict ancestor, while its own rule-prefix walk remains the authority for same-attribute order. Reuse the existing O(depth) prefix walk rather than scanning every unresolved attribute for every candidate. +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: @@ -612,6 +664,15 @@ Tests: - 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 @@ -620,6 +681,7 @@ 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` @@ -651,9 +713,9 @@ foreach ($rules as $index => $rule) { 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, keep `collectContext()`'s `is_string($parsedName)` guard, and consume the parsed name/parameters for strings, array tuples, and callback-bearing `Stringable` presence objects. +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, return `compileAllDelegated($rules)` when any parsed parameter is non-scalar, as required by step 3. -`compileAllDelegated()` does not need parsed context after the dead size-mode metadata is removed. Let `compileRuleDelegated()` continue parsing each rule once as it emits the delegated check; do not add a second generalized compilation abstraction solely to share a short control flow. +`compileAllDelegated()` does not need parsed context after the dead size-mode metadata is removed. Let `compileRuleDelegated()` continue parsing each rule once as it emits the delegated check; do not add a second generalized compilation abstraction solely to share a short control flow. 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`: @@ -665,14 +727,28 @@ Remove every compiler write and delete `RuleCompiler::isImplicitRule()` with its 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. -The compiled loop must also stop from state it already owns instead of calling the parsing-based `shouldStopValidating()` after a message: +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 the legacy benchmark loop and protected API compatibility; only the compiled executor bypasses its repeated parsing. +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: @@ -683,7 +759,10 @@ Replace tests that pin dead fields with behavior or consumed-output assertions: - 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; -- all-delegated subclass plans retain correct ordered behavior without stored context. +- 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 @@ -763,6 +842,7 @@ Repair and verify the benchmark harness before changing performance-sensitive va - 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; @@ -796,10 +876,14 @@ vendor/bin/phpunit tests/Validation/ValidationPlanExecutorTest.php \ vendor/bin/phpunit tests/Validation -bin/run-database-tests.sh sqlite --filter=ValidationBatchDatabaseCheckerTest -bin/run-database-tests.sh mysql --filter=ValidationBatchDatabaseCheckerTest -bin/run-database-tests.sh mariadb --filter=ValidationBatchDatabaseCheckerTest -bin/run-database-tests.sh pgsql --filter=ValidationBatchDatabaseCheckerTest +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: @@ -820,30 +904,34 @@ Do not weaken assertions to accommodate the implementation. Any failure must be ## Acceptance checklist -- [ ] 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. -- [ ] Pipe-delimited and array rule forms compile to the same correct behavior. -- [ ] Laravel's generic safe-object canonicalization is restored; fluent `in` / `not_in` and callback-free presence rules use caching/inlining without a class allowlist. -- [ ] Callback-bearing presence rules retain their objects and query callbacks, remain delegated, and produce no lookup key. -- [ ] O(n) wildcard expansion, immutable worker-lifetime plan caching, and the single execution loop remain intact. -- [ ] Typeless `min` / `max` / `size` / `between` inline with runtime value dispatch; numeric semantics come only from the canonical `$defaultNumericRules`, including `Decimal`. -- [ ] Common `required|integer|exists`, `required|max:255|exists`, `email|exists`, `date|exists`, and `required|array|exists` wildcard shapes remain batched. -- [ ] `stopOnFirstFailure` uses ordinary presence execution so speculative SQL cannot replace the first validation failure or poison a PostgreSQL transaction; exclusion pre-evaluation remains enabled. -- [ ] No invalid value is submitted merely because preflight could not prove its prefix; uncertain probes fall back only if execution reaches presence. -- [ ] Boolean presence candidates use the ordinary verifier path; driver-specific binding is never approximated by the batch optimizer. -- [ ] 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. -- [ ] 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. -- [ ] Pre-excluded, unresolved-parent-excluded, absent-sometimes, empty, nullable, and proven-failing values issue no presence query. -- [ ] Case-insensitive/collation-equivalent `unique` values cannot false-pass. -- [ ] Array-valued `exists` agrees with database `DISTINCT` semantics, including chunk boundaries. -- [ ] No optimizer result is stored in a shared plan, static property, or coroutine context. -- [ ] 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. -- [ ] Unused extensions do not suppress optimization; used validator mutators suppress only exclusion pre-evaluation and affected descendant batches. -- [ ] Inline messages cannot inherit transient numeric state. -- [ ] Resource-valued JSON fails cleanly in inline and delegated execution, with no duplicate JSON predicate. -- [ ] Conditional fluent closures are evaluated once during parser explosion, matching Laravel, and compiler context/emission share one local parsed-pair list. -- [ ] Compiled bail/uploaded/implicit stopping uses existing plan/failure state and placeholder-cleaned attribute keys without reparsing rules. -- [ ] `SizeMode`, `DatabasePresenceRule`, presence-metadata methods, dead plan fields/compiler writes, duplicate implicit-rule knowledge, and obsolete helpers/tests/comments are removed. -- [ ] Every retained source comment and docblock describes the final design; no superseded optimizer explanation remains. -- [ ] The validation database suite runs through the existing MySQL, MariaDB, PostgreSQL, and SQLite workflow discovery. -- [ ] 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. -- [ ] Focused, full validation, database-matrix, benchmark, static-analysis, formatting, and final repository checks pass. +- [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. From 62335b1ef8928aed99e7ed1a5a4b2156b02515fb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:10:26 +0000 Subject: [PATCH 20/22] fix(validation): avoid PHP 8.5 NAN coercion warnings Reject non-finite numeric sizes before preflight exponent inspection so PHP 8.5 never string-casts NAN speculatively. Normalize NAN and infinities to their historical string representations at the shared size boundary. Inline and delegated comparisons therefore preserve the same Brick Math exceptions and messages without emitting warnings. Cover NAN, positive infinity, and negative infinity through both exact-base and subclass execution paths. --- .../src/Concerns/ValidatesAttributes.php | 15 +++++++++- src/validation/src/PlanExecutor.php | 4 +-- .../ValidationCompiledExecutionTest.php | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 96d7ec478..da49e23fd 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -2594,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/PlanExecutor.php b/src/validation/src/PlanExecutor.php index 9ea8d0b94..27c641a75 100644 --- a/src/validation/src/PlanExecutor.php +++ b/src/validation/src/PlanExecutor.php @@ -234,8 +234,8 @@ protected function canPreflightInline(InlineCheck $check, mixed $value): bool CheckType::SizeMax, CheckType::SizeBetween, CheckType::SizeExact => ! ($check->param['numeric'] && is_numeric($value)) - || (! Str::contains((string) $value, 'e', ignoreCase: true) - && (! is_float($value) || is_finite($value))), + || ((! is_float($value) || is_finite($value)) + && ! Str::contains((string) $value, 'e', ignoreCase: true)), default => false, }; } diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index 22ad15385..131ec3a24 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; @@ -168,6 +169,35 @@ function (int $scale, string $attribute, string $value) use (&$calls): bool { } } + 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']); From 7a90b13a992c1f8af1f386c6f2d09fe8711067f9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:31:37 +0000 Subject: [PATCH 21/22] perf(validation): reuse parsed rules in delegated fallback The base compiler parses every rule before deciding whether a non-scalar parameter requires an all-delegated plan. Reuse those aligned parsed pairs when emitting the fallback instead of parsing the uncacheable attribute a second time on every validation execution. Keep compileAllDelegated() unchanged for validator subclasses and keep the parsed-pair invariant private to compile(). Add a regression covering the callback-bearing presence-rule path and proving the original rule is stringified once and retained for delegated execution. --- src/validation/src/RuleCompiler.php | 17 +++++++++----- .../Validation/ValidationRuleCompilerTest.php | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/validation/src/RuleCompiler.php b/src/validation/src/RuleCompiler.php index 6eeab97d2..eece5c106 100644 --- a/src/validation/src/RuleCompiler.php +++ b/src/validation/src/RuleCompiler.php @@ -35,7 +35,11 @@ public static function compile(array $rules, array $numericRules): AttributePlan foreach ($parsedRules as [, $parameters]) { if (array_any($parameters, static fn (mixed $parameter): bool => ! is_scalar($parameter))) { - return self::compileAllDelegated($rules); + foreach ($rules as $index => $rule) { + self::compileRuleDelegated($rule, $plan, $parsedRules[$index]); + } + + return $plan; } } @@ -51,9 +55,8 @@ public static function compile(array $rules, array $numericRules): AttributePlan /** * Compile all rules as DelegatedCheck (no inlining). * - * Used for Validator subclasses and attributes with non-scalar rule - * parameters. Retains meta flags for attribute-level gating while keeping - * every declared rule available to delegated execution. + * 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() */ @@ -166,8 +169,10 @@ private static function compileRule(mixed $rule, array $parsedRule, AttributePla * * 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) { $plan->checks[] = new DelegatedCheck( @@ -178,7 +183,7 @@ private static function compileRuleDelegated(mixed $rule, AttributePlan $plan): return; } - [$ruleName, $parameters] = ValidationRuleParser::parse($rule); + [$ruleName, $parameters] = $parsedRule ?? ValidationRuleParser::parse($rule); if (! is_string($ruleName) || $ruleName === '') { return; diff --git a/tests/Validation/ValidationRuleCompilerTest.php b/tests/Validation/ValidationRuleCompilerTest.php index 791bbc087..ca80b6f61 100644 --- a/tests/Validation/ValidationRuleCompilerTest.php +++ b/tests/Validation/ValidationRuleCompilerTest.php @@ -605,6 +605,28 @@ public function __toString(): string $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() { $types = ['ip', 'ipv4', 'ipv6', 'ulid', 'json', 'ascii', 'hex_color', 'mac_address']; From 6be4bc719779cf7417984e452bb17f9c8158e0dc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:31:52 +0000 Subject: [PATCH 22/22] docs(validation): record single-pass fallback compilation Replace the completed plan's superseded instruction to reparse all-delegated fallback rules. Document that the base compiler feeds its existing parsed pairs directly into delegated emission while the subclass entry point keeps its public signature and standalone parsing behavior. --- ...2137-components-validation-audit-remediation-plan-codex.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index a60004fc8..c10b89ecd 100644 --- 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 @@ -713,9 +713,9 @@ foreach ($rules as $index => $rule) { 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, return `compileAllDelegated($rules)` when any parsed parameter is non-scalar, as required by step 3. +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. -`compileAllDelegated()` does not need parsed context after the dead size-mode metadata is removed. Let `compileRuleDelegated()` continue parsing each rule once as it emits the delegated check; do not add a second generalized compilation abstraction solely to share a short control flow. 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. +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`: