diff --git a/composer.json b/composer.json index f1691cb05..1e48f837f 100644 --- a/composer.json +++ b/composer.json @@ -343,6 +343,7 @@ "Hypervel\\NestedSet\\NestedSetServiceProvider", "Hypervel\\Notifications\\NotificationServiceProvider", "Hypervel\\ObjectPool\\ObjectPoolServiceProvider", + "Hypervel\\Pagination\\PaginationServiceProvider", "Hypervel\\Passkeys\\PasskeysServiceProvider", "Hypervel\\Fortify\\FortifyServiceProvider", "Hypervel\\Grpc\\GrpcServiceProvider", diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 07aeabcf5..d0005a0f6 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** None. -- **Ledger entries required for the active work:** None. -- **Pending revalidation carried into the active work:** None. +- **Active package or work unit:** None. `pagination` is complete; detail plan `2026-08-06-0928-pagination-correctness-current-parity-and-query-contracts.md`. +- **Ledger entries required for the active work:** None. The completed Pagination work is recorded under `Complete Pagination correctness, current parity, and query contracts`, with its cross-package findings also recorded at their owning package entries. +- **Pending revalidation carried into the active work:** None. The completed View audit owns `ComponentAttributeBag::data()`; Translation's `__()` conditional type remains routed to its active owner audit. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1147,11 +1147,20 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `permission-04` | `permission` | `permission` (targeted correction complete); later full `permission` audit | `Harden Eloquent identity and partial-projection safety`; finding `permission-04` | | `permission-05` | `permission` | `permission` (targeted correction complete); later full `permission` audit | `Harden Eloquent identity and partial-projection safety`; finding `permission-05` | | `fortify-02` | `fortify` | `fortify` (targeted correction complete); later full `fortify` audit | `Harden Eloquent identity and partial-projection safety`; finding `fortify-02` | -| `pagination-01` | `pagination` | `pagination` (targeted correction complete); later full `pagination` audit | `Harden Eloquent identity and partial-projection safety`; finding `pagination-01` | -| `pagination-02` | `pagination` | `pagination` (targeted correction complete); later full `pagination` audit | `Harden Eloquent identity and partial-projection safety`; finding `pagination-02` | +| `pagination-01` | `pagination` | `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `pagination-01` | +| `pagination-02` | `pagination` | `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `pagination-02` | +| `collections-15` | `collections` | `collections` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `collections-15` | +| `support-32` | `support` | `support` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `support-32` | +| `support-33` | `support` | `support` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `support-33` | +| `sanctum-02` | `sanctum` | `sanctum` (targeted correction complete); later full `sanctum` audit | `Complete Pagination correctness, current parity, and query contracts`; finding `sanctum-02` | +| `api-client-01` | `api-client` | `api-client` (targeted correction complete); later full `api-client` audit | `Complete Pagination correctness, current parity, and query contracts`; finding `api-client-01` | +| `database-24` | `database` | `database` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `database-24` | +| `database-25` | `database` | `database` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `database-25` | +| `scout-41` | `scout` | `scout` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `scout-41` | +| `routing-25` | `routing` | `routing` and `pagination` (revalidation complete) | `Complete Pagination correctness, current parity, and query contracts`; finding `routing-25` | | `queue-41` | `database`, `queue` | `database`, `queue`, and `notifications` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `queue-41` | -| `scout-01` | `scout` | `scout` (targeted correction complete); later full `scout` audit | `Harden Eloquent identity and partial-projection safety`; finding `scout-01` | -| `scout-02` | `scout` | `scout` (targeted correction complete); later full `scout` audit | `Harden Eloquent identity and partial-projection safety`; finding `scout-02` | +| `scout-01` | `scout` | `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; carried finding `scout-01` | +| `scout-02` | `scout` | `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; carried finding `scout-02` | | `notifications-08` | `notifications` | `notifications` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `notifications-08` | | `http-04` | `http` | `http` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `http-04` | | `http-05` | `http` | `http` (revalidation complete) | `Harden Eloquent identity and partial-projection safety`; finding `http-05` | @@ -1269,7 +1278,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `routing` - [x] `view` - [ ] `translation` -- [ ] `pagination` +- [x] `pagination` - [ ] `socialite` - [ ] `sanctum` - [ ] `fortify` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index d66fdda72..078dfd28f 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -220,6 +220,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Implementation:** Eager and lazy keying now normalize enum and stringable objects consistently; lazy key lookup tracks requested keys instead of counting yields; and keyed sums forward both callback arguments. The shared collection contract now includes `reduceInto()`, truthful key-preserving random results, numeric sum accumulation, and conditional integer results for callback-free `times()`. Arr keeps native array fast paths while correctly streaming Traversables for `first()`, `every()`, and `some()`; callback-free `last()` retains only the final value, while callback-based `last()` retains ordered key/value occurrences so it can preserve reverse callback order and short-circuiting without collapsing duplicate keys. The impossible ArrayAccess mutation was removed from `Arr::push()`. Lazy iterator normalization accepts both IteratorAggregate and plain Iterator sources, including generators, without changing scalar-returning callable runtime support. Deprecated aliases and their stale public docs/tests were removed with intentional-omission markers. Split-package metadata, named-argument parameter names, public annotations, test signatures, and user documentation now match the supported behavior. - **Regression tests:** Runtime coverage exercises backed-enum keying, duplicate and empty lazy key sets, keyed sums, mutable reduction, preserved random keys, every accepted Arr iterable shape and short-circuit rule, duplicate iterator keys, exact reverse `last()` callback order, ArrayAccess parameter names, and LazyCollection combine/zip with ArrayIterator and generators. Static fixtures cover Arr refinements; eager, lazy, and contract-level random/sum behavior; iterable-returning lazy factories; callback-free and callback-based `times()`; helper conditionals; and the new reduction API. Removal markers keep future parity work from silently restoring deprecated aliases. - **Later Routing revalidation:** `collections-14` corrects the existing multi-group `groupBy()` callback contract once on `Enumerable`; `Collection` and `LazyCollection` inherit it while retaining their title docblocks. Focused type coverage and Routing's compiled-index consumer revalidate the completed package without runtime code or cost. +- **Later Pagination revalidation:** `collections-15` makes `EnumeratesValues::toJson()` fail through `JsonException` in one encoding pass while preserving caller flags. Focused collection and delegated pretty-JSON coverage revalidates the completed package without new runtime machinery. - **Performance and complexity:** Arrays retain their native PHP fast paths. `Arr::first()` improves Traversable behavior from full materialization to a short-circuiting scan; callback-free Traversable `last()` uses O(1) memory; only callback-based Traversable `last()` stores ordered pairs, which is required to preserve the array path's observable reverse callback and exception semantics. Lazy iterator normalization adds one `instanceof` only when constructing an iterator, not per item. The `times()` correction and other type/documentation changes execute no production code. The only measured hot-path costs are the owner-approved key forwarding in `sum()` and ordinary `Arr::last()` iterable guard; enum scalar work and lazy timing become cheaper. No lock, context state, registry, cache, compatibility layer, or speculative abstraction was added. - **Validation:** All affected Collections runtime tests, both real PHPStan configurations, strict split-package manifest validation, stale-reference sweeps, and `git diff --check` pass. The complete `composer fix` gate is green. Pre-implementation consensus, every focused unexpected-finding review, fresh self-review, final code-review sign-off, and owner pre-commit approval are complete. - **Assessment:** Collections remains a provider-free, lock-free value/iterator package. The changes correct verified parity, iterable, typing, metadata, and test-isolation defects at their existing boundaries, add only current general-purpose APIs with real upstream provenance, and avoid production lifecycle machinery the package does not need. @@ -498,6 +499,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Later HTTP revalidation:** HTTP regenerated truthful client facade metadata. `routing-01` widened URL/redirect facade forwarding through the existing Routing owner and restored required expiration on temporary signed `Uri` routes. Focused Support, Routing, and URL-signing coverage plus the full gate revalidated the completed package without adding normalization or compatibility machinery to Support. - **Later Mail revalidation:** `support-28` makes MailFake a faithful one-shot delivery boundary and refreshes the Mail facade from its concrete forwarded surface. `support-29` restores current single-label URL support across Str, Stringable, Mail, and Validation. `support-30` and `support-31` correct fake assertion types and regenerate the affected Notification facade metadata without adding runtime state. Current upstream MailFake and NotificationFake coverage passes; the separately missing EventFake test port remains recorded in `docs/todo.md`. - **Later Validation revalidation:** `validation-18` regenerates the Validator facade from its actual Factory owner, removing the unrelated concrete Validator surface while retaining the nullable verifier and DNS-faking APIs. Facade-documenter coverage and the complete gate revalidate Support without changing its runtime code. +- **Later Pagination revalidation:** `support-32` makes `Fluent` and `MessageBag` fail through `JsonException` in one encoding pass while preserving caller flags and delegated pretty output. `support-33` ports `Lottery::choose()`'s conditional result type. Focused runtime and max-level type coverage revalidates the completed package without new state or runtime abstraction. - **Validation and review:** Every changed Support, Auth, Foundation-testing, Queue, Testbench, and cleanup regression is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, broad stale-reference/API/parity scans, a fresh full-diff caller/callee and lifecycle review, and independent code review are complete. The final review added strict PendingBatchFake comparisons, confirmed the three local file-mode checks are preferable to a Laravel-divergent public Filesystem helper, and signed off with no remaining finding. - **Laravel-facing result:** Public API and configuration remain compatible. The work restores current Laravel fake, string, validated-input, Carbon metadata, and edge-case behavior; adds the matching facade metadata and documentation; and retains only the documented Hypervel omissions for deferred providers, `laravel/ui`, and Laravel's directly deprecated `loadFactoriesFrom()` surface. - **Assessment:** The final work fixes demonstrated utility, file-publication, fake, reflection, cleanup, and authentication-timing defects at their owning boundaries. Ordinary request paths are unchanged; the only new per-operation cost is one Timebox clone when an authentication or password-reset operation is actually timed. No lock, retry, coroutine context, registry, worker cache, general transaction/callable/serialization abstraction, or speculative compatibility machinery was added. The design is complete without overengineering. @@ -1213,7 +1215,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and compatibility:** Normal Database queries gain no container lookup, context operation, lock, retry, logging, yield, or registry. Stable model construction adds one static owner-map `isset()`, and first boot alone reads the coroutine ID and may use the existing Mutex. Redis release adds one local extension-state read and one boolean; same-connection publication adds one owner-ID comparison only when publishing a pin. Per-key cast merging and indexed raw-SQL substitution remove work. SQLite normalization occurs at setup, lifecycle events are cold task/start boundaries, and exhaustive cleanup is exceptional or terminal. Public Laravel APIs remain intact; supported current APIs are restored, while internal Swoole adaptations remain at their lowest owners. - **Implementation:** Added exact task and pre-fork lifecycle events and Database/Redis listeners; replaced reflective task cleanup with exact resolver/proxy ownership; deduplicated Redis terminal defers without delaying callback release; made Redis event cleanup, WATCH, native DISCARD, mode checks, purge, and fork cleanup truthful; centralized SQLite classification and serialized shared-memory ownership; corrected RefreshDatabase and parallel/Testbench consumers; repaired transaction publication, retry, rollback, callback, disconnect, and query-log state; made first Eloquent boot coroutine-safe; ported the complete accepted current Laravel Database surface and both approved performance corrections; made the connection contract expose the implementation's existing rollback-level parameter; completed split metadata, provenance, omission markers, facade metadata, and concise user documentation; and removed every superseded listener, literal classifier, dependency, comment, and test assumption. - **Regression tests:** Deterministic coverage spans task and fork failure precedence; exact Database and Redis wrapper ownership; copied-context and callback-immediate Redis release; real MULTI/PIPELINE/WATCH/DISCARD state; full SQLite URI classification, canonical refresh, mixed RefreshDatabase ownership, and one-owner concurrency; transaction begin/commit/rollback/disconnect and manager-callback failures; Queue's typed connection consumer rolling back a failed-job transaction to level zero; retry suppression and primary-failure preservation; recursive, concurrent, failed, and post-publication model boot; all supported current Query, Eloquent, Schema, migration, connector, provider, exception, metadata, facade, documentation-facing call shapes, and external MySQL, MariaDB, PostgreSQL, SQLite, Redis, and Valkey behavior. -- **Cross-package revalidation:** The work closes carried `database-01` through `database-04`, `redis-01` and `redis-02`, `pool-04`, `pool-05`, `pool-08`, `context-04`, `support-02`, `foundation-06`, and the Database side of `database-03`. Core and Server own the new lifecycle producers; the full Redis audit revalidated `redis-03` through `redis-08`; Foundation, Testing, and Testbench consume the SQLite and transaction-test boundaries; Scout and NestedSet consume the corrected model-boot publication; Telescope's aggregate SQL expectation follows the corrected grammar. The later Queue audit added `database-14` and revalidated the rollback-level contract through its failed-job timeout cleanup. The Eloquent identity work adds `database-15` through `database-20` and the Database side of `queue-41`, correcting stored-row comparison, relation-key normalization, partial persistence identity, compound pivots, collection membership, soft-delete state, and queueable collection publication without changing the pooled connection lifecycle. The Scout audit adds `database-21` through `database-23`: shared chunk owners reject nonpositive counts, `CollectedBy` uses constructor-owned positional/named arguments before caching, and descending cursors accept the same string/integer domain as ascending cursors. Database and Scout consumer coverage and the complete gate are green. +- **Cross-package revalidation:** The work closes carried `database-01` through `database-04`, `redis-01` and `redis-02`, `pool-04`, `pool-05`, `pool-08`, `context-04`, `support-02`, `foundation-06`, and the Database side of `database-03`. Core and Server own the new lifecycle producers; the full Redis audit revalidated `redis-03` through `redis-08`; Foundation, Testing, and Testbench consume the SQLite and transaction-test boundaries; Scout and NestedSet consume the corrected model-boot publication; Telescope's aggregate SQL expectation follows the corrected grammar. The later Queue audit added `database-14` and revalidated the rollback-level contract through its failed-job timeout cleanup. The Eloquent identity work adds `database-15` through `database-20` and the Database side of `queue-41`, correcting stored-row comparison, relation-key normalization, partial persistence identity, compound pivots, collection membership, soft-delete state, and queueable collection publication without changing the pooled connection lifecycle. The Scout audit adds `database-21` through `database-23`: shared chunk owners reject nonpositive counts, `CollectedBy` uses constructor-owned positional/named arguments before caching, and descending cursors accept the same string/integer domain as ascending cursors. The Pagination audit adds `database-24` and `database-25`: both relation families accept cursor objects, Eloquent restores concrete generic paginator results, and Query's cursor per-page boundary states its existing non-null requirement. Database, Scout, and Pagination consumer coverage and the complete gate are green. - **Validation and review:** Every changed test file and affected package group passed during implementation. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Database and Redis split manifests validate, `git diff --check`, package-checklist parity, broad stale-reference/classifier/dependency scans, and a fresh full-diff caller/callee, lifecycle, transaction, API, documentation, hot-path, retained-state, and overengineering self-review are complete. Independent post-implementation code review is signed off with no remaining findings. - **Laravel-facing result:** Current supported Laravel Database APIs, signatures, member ordering, tests, and task-first documentation are restored. Intentional differences are limited to Swoole/coroutine ownership, pooled-connection safety, truthful contract corrections, unsupported drivers/dynamic connections, and deliberate omission of directly deprecated forwarding. Redis's Hypervel/Hyperf-derived internals retain their public Laravel-shaped command surface while fixing lifecycle and native-state defects. - **Assessment:** Every accepted Database finding and linked Redis ownership correction is implemented at its lowest owner. The result removes reflective and duplicated cleanup, repeated defer retention, inconsistent classifiers, false shared-PDO capacity, stale manager state, and quadratic or over-broad work while adding only the owner-approved noise-level correctness checks. It contains no workaround, speculative mechanism, compatibility shim, hot-path synchronization, unresolved accepted defect, or stale superseded path. @@ -1552,7 +1554,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Design and rejected machinery:** Missing loaded state is checked only at the latest boundary that cannot produce a truthful result without it. Fresh models, loaded SQL nulls, casts/accessors, zero/string-zero, UUIDs, custom keys, compound pivot originals, and Laravel method signatures remain supported. No global strict-mode change, automatic reload, recovery query, registry, synthetic identity, second traversal, new exception type, compatibility branch, state cache, lock, context slot, or consumer-local workaround is added. - **Implementation:** Database now separates keyless objects from stored rows, protects shared persistence and pivot predicates, retains deliberate collection dictionary behavior, and requires truthful soft-delete state. Permission validates cache projections and every model-derived scope, context, target, persisted owner, and deletion-cleanup identity before SQL or side effects; its explicit invalidators use the same fail-closed key identity as tolerant cache reads. Fortify, Pagination, Queue, Scout, Notifications, JSON:API, and Testing reject or contain missing state at their existing owners. Current Laravel JSON:API include and empty-fieldset fixes are ported from their complete originating changes. The invalid Notifications fixture now proves collection-shaped serialization with a restorable ID. Superseded fallback logic, false type claims, malformed assertions, and stale comments are removed. -- **Cross-package revalidation:** Completed Database owns `database-15` through `database-20` and the collection side of `queue-41`; completed Queue owns its single-model side. The completed Notifications audit revalidated queued model wrapping and `notifications-08`, including the public read/unread scopes through the required builder form: the same-named instance predicates shadow static scope dispatch, so `DatabaseNotification::query()->read()` / `unread()` are the working APIs. The full HTTP audit retained `http-04` through `http-06` while completing the broader current-Laravel JSON:API update. Permission, Fortify, Pagination, Scout, and Testing retain their targeted findings for later full audits; the remaining Testing assertion-suite port is recorded without adding temporary runtime behavior. +- **Cross-package revalidation:** Completed Database owns `database-15` through `database-20` and the collection side of `queue-41`; completed Queue owns its single-model side. The completed Notifications audit revalidated queued model wrapping and `notifications-08`, including the public read/unread scopes through the required builder form: the same-named instance predicates shadow static scope dispatch, so `DatabaseNotification::query()->read()` / `unread()` are the working APIs. The full HTTP audit retained `http-04` through `http-06` while completing the broader current-Laravel JSON:API update. Pagination and Scout have completed their full audits and revalidated `pagination-01`, `pagination-02`, `scout-01`, and `scout-02`; Permission, Fortify, and Testing retain targeted findings for their later full audits. The remaining Testing assertion-suite port is recorded without adding temporary runtime behavior. - **Regression tests:** Counterfactual coverage spans every key/table/connection operand; partial save/select/delete/refresh/increment paths; original and current Pivot/MorphPivot identities; scalar and model collection comparisons; soft-delete state; Permission cache, guard, positive/negative scope, context, all eleven persisted assignment/removal/sync entry points, reverse APIs, Role/Permission/subject deletion cleanup, soft-delete bypass, and explicit keyless cache invalidation; Fortify challenge and state helpers; every cursor item shape; Queue single/collection publication and notification wrapping; Scout default/custom keys; all four notification state methods; root and relationship JSON:API IDs, includes, and fieldsets; and both Testing assertion surfaces. - **Performance and compatibility:** Ordinary valid paths add bounded local null or raw-attribute checks only. Validation stays inside existing traversals and registrar initialization; no production path gains I/O, an additional collection pass, allocation-heavy machinery, synchronization, yield, or retained state. Laravel public/protected names and signatures remain intact. Behavioral differences are limited to rejecting outputs or mutations that cannot identify, restore, or truthfully describe persisted state, plus current upstream JSON:API fixes. - **Validation and review:** Every changed test file and affected package group is green. The authoritative `composer fix` completed with formatting and both PHPStan configurations clean, followed by the complete parallel components, Testbench, and dogfood suites. Final caller/callee, Laravel API, hot-path, stale-code, and overengineering review is complete. Independent review re-read the full source and test diff, reran the affected gates, and signed off after all corrections were incorporated. @@ -1638,6 +1640,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved Laravel-facing gates:** The owner approved top-level `scout.after_commit` / `SCOUT_AFTER_COMMIT`; three observer-required `SearchableInterface` methods; model-selected/container-substituted builders and paginators; Database raw pagination honoring its advertised model capability; Typesense rejecting unknown comparison operators; and four temporary wrapper objects per remote Typesense operation to eliminate unbounded per-name retention. Scout job-option properties remain intentionally untyped because current Laravel supports subclasses that redeclare them. - **Approved Database behavior correction:** The owner approved `database-21`, changing nonpositive chunk counts from Laravel's successful or unbounded behavior to the `InvalidArgumentException` already used by the lazy chunk family; public names and signatures remain unchanged. - **Cross-package transaction:** Database owns `database-21` through `database-23`; Scout consumes the guarded chunk and truthful descending-cursor contracts, while `database-22` corrects Database's own named-collection construction. Foundation owns `foundation-17` and `foundation-18`; Scout consumes truthful Meilisearch task results, removes its duplicate waiter, and inherits exact Meilisearch and Algolia cleanup-task ownership. Neither receives a local workaround. Scout preserves `database-10` model publication, `queue-41` restorable identity, `scout-01` default-key rejection, `scout-02` custom queueable IDs, and `support-02`'s rule that value-domain enums remain raw until each remote string formatter owns conversion. The completed Database and Foundation entries and dependency index record the finished revalidation. +- **Later Pagination revalidation:** `scout-41` clamps all four public Scout page producers to at least page one before dispatch to any engine. Raw-engine regressions prove zero and negative values cannot reach first-party or third-party engines; no engine-local workaround or extra I/O was added. - **Important rejected concerns:** Do not add a command index-name trait, Builder-wide enum normalization, Collection raw-pagination changes, a custom integer parser, a second quiet wait API, generic retries/cancellation/failure aggregation, a trait-declared `$scoutBuilder`, native types on redeclarable Scout job properties, reflection/LRU access to Typesense internals, per-coroutine clients, compatibility shims, or a new transaction layer for direct nonqueued collection calls. - **Performance and compatibility:** Normal operations add only approved fresh parameterized construction, small filter checks, three queued-job config reads, and four short-lived Typesense wrappers. Optional-event construction/dispatch and multiple Typesense network calls are removed, while unbounded wrapper retention is eliminated. Laravel APIs are restored or preserved except for the explicitly approved Hypervel configuration, coroutine, concurrency, Algolia-4, error-truthfulness, and Typesense operator decisions, plus the approved `database-21` invalid-input correction. - **Implementation and regressions:** Scout now uses structured comparisons across every engine; exact remote escaping, scalar/operator validation, targets, absence classes, and request counts; one force-delete owner; nesting-safe coroutine suppression; through-aware macros; substitutable fresh builders and paginators; listener-gated events; current job options and unique jobs; exact query-free removal identities; unified boolean after-commit configuration; first-failure concurrent imports; validated ordered ranges; and bounded Typesense wrapper ownership. Regressions cover these paths, strict platform-extreme range types, Database chunk/cursor/attribute owners, split metadata, public docs, config, and test typing. @@ -1776,7 +1779,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved difference and rejected concerns:** The protected request rebinder is deliberately absent because replacing the container binding cannot update coroutine-local request ownership and retaining the hook would preserve a cross-coroutine race. Keep `FORCE_HTTPS`; a package prefix rename has no concrete benefit. Do not add request-scoped route services, broad cache invalidation, a middleware graph cache or visited set, route stacks or registries, object-ID allocators, WeakMaps, full-table memoization, Redis reservation/rollback or multi-key transaction machinery, context cleanup protocols, generic dependency scanners, compatibility wrappers, or wider registrar input behavior without a supported failing case. - **Implementation and boundaries:** Route declaration now has current metadata, middleware-exclusion, resource-verb, fluent-type, and serialization behavior. Route collections use exact domain buckets and indexed compiled name/action/method lookups without retaining a materialized route table. Route invocation state is coroutine-local to the exact route object; framework dispatch retains that object while the state can be read and never reads its context slots after release. Installing a genuinely different container clears that route's coroutine-local controller together with every property cache derived from the old container. Middleware groups fail clearly on cycles while preserving valid falsy identifiers. URL signing, previous paths, and relative base removal reject malformed input or normalize at their public owners. Redis limiter protocol state is truthful, ordinary throttling remains atomic, after-based limits use the required two-phase ownership, and mutable throttle state is transient. Providers use typed required configuration and normal container resolution. Dead rebinding, stale cache names, false contracts, missing dependencies, and superseded documentation are removed. -- **Cross-package revalidation:** Carried `contracts-05`, `reflection-02`, `container-08`, `support-02`, and `routing-01` are revalidated through the complete Routing surface. `routing-04` applies the same targeted correction to Wayfinder, whose later full audit must preserve it. `redis-24` is complete across Redis and Routing; current Laravel shares the tuple defect, but no upstream issue or pull request was opened. `routing-12` includes the targeted Encryption configuration correction. `routing-18` revalidates Auth's redirect consumer. `collections-14` is complete across Collections and Routing. No consumer-local workaround was added. +- **Cross-package revalidation:** Carried `contracts-05`, `reflection-02`, `container-08`, `support-02`, and `routing-01` are revalidated through the complete Routing surface. `routing-04` applies the same targeted correction to Wayfinder, whose later full audit must preserve it. `redis-24` is complete across Redis and Routing; current Laravel shares the tuple defect, but no upstream issue or pull request was opened. `routing-12` includes the targeted Encryption configuration correction. `routing-18` revalidates Auth's redirect consumer. `collections-14` is complete across Collections and Routing. The later Pagination audit adds `routing-25`, restoring `Route::domain()`'s conditional get/set type and a complete current Routing type fixture without runtime code. No consumer-local workaround was added. - **Regression and static coverage:** Counterfactual tests prove inherited and falsy middleware exclusions; every metadata path and merge shape; allowed and rejected closure hydration; invalid and valid signature shapes; off-site, root, base-path, encoded, and regex-significant URL behavior; direct and indirect middleware cycles plus valid sibling reuse; domain replacement and every merged lookup surface; no-argument, method, action, dynamic, exception, numeric-name, and alternate-verb compiled behavior; nested route dispatch and route-local parameters, originals, controllers, and current-route state; fresh, occupied, expired, clamped, ordinary, qualifying-after, and excluded-after Redis paths; identical-container cache retention and complete different-container invalidation, including the exact coroutine-local controller; merge-disabled required configuration; resource verbs, exact split metadata, the collection type contract, and carried consumers. PHPStan separately covers numeric-URI method-map contracts and registrar fluent types; lifecycle warnings were reviewed directly. - **Performance and complexity:** Ordinary matching retains the compiled Symfony matcher and now also preserves computed middleware, dispatcher, controller-lifetime, and resolved middleware caches when the same container is reinstalled. A genuinely different container performs one coroutine-context removal alongside its existing cache invalidation; ordinary dispatch returns before that work. Compiled method and action lookups no longer rebuild the complete route table, including on attacker-reachable alternate-verb scans. Normal Redis throttles retain one round trip; only `after()` semantics add the unavoidable precheck before a qualifying acquire. Transient throttle construction adds one small allocation per pipeline resolution and removes worker-retained request-key maps. Other changes are boot-time, lazy index construction, bounded local checks, docs, metadata, or types. No request path gains a lock, retry, yield, poll, serializer layer, container loop, network preflight, unbounded cache, or retained request state. - **Laravel-facing result:** Supported current Routing APIs, named arguments, protected extension points, route ordering, facades, configuration, metadata, controller attributes, resource behavior, and URL semantics are preserved or restored. Malformed query and configuration shapes fail at truthful public owners. The only removed Laravel extension point is the approved protected request rebinder, whose `RequestContext` replacement and compatibility effect are documented. @@ -1807,6 +1810,36 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Every changed test file and the complete Validation unit/integration group are green. The authoritative `composer fix` gate passed formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, and dogfood. `git diff --check`, metadata/facade regeneration, stale-symbol scans, and fresh caller/callee, lifecycle, API, hot-path, dead-code, overengineering, and independent code review are complete. - **Assessment:** The implementation fixes each accepted finding at its owning boundary and removes stale metadata and unsafe fast-path assumptions without adding speculative machinery. No accepted defect, TODO, workaround, unintended Laravel API break, or meaningful performance regression remains. +### Complete Pagination correctness, current parity, and query contracts + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh caller/callee and hot-path self-review, and independent code review are signed off. The audit covered Pagination source and tests; Database, Scout, Collections, Support, Sanctum, API Client, Routing, Contracts, Foundation, Translation, and View touchpoints; split/root metadata and public docs; current Laravel source and tests; and originating pull requests #59699, #60586, and #60968. The detailed design is recorded in [`2026-08-06-0928-pagination-correctness-current-parity-and-query-contracts.md`](2026-08-06-0928-pagination-correctness-current-parity-and-query-contracts.md). + +| ID | Category | Severity | Confidence | Final decision | +|---|---|---|---|---| +| `pagination-03`, `pagination-04` | Cursor boundary and value defects | Major | High | Reject malformed envelopes before strict construction while retaining lenient base64 decoding, and preserve mixed database order values. | +| `pagination-16` | Structured cursor parameter defect | Major | High | Reject array-valued parameters at Cursor construction so decoded, direct, and generated cursors cannot be silently flattened into different query values. | +| `pagination-05` | Query append defect | Major | High | Preserve integer query keys and supported values without renumbering. | +| `pagination-06` | Explicit page defect | Minor | High | Distinguish omitted pages from explicit zero and reject invalid Scout pages before engine dispatch. | +| `pagination-07`–`pagination-09` | Resolver, view, and static contract defects | Minor | High | Read `RequestContext` once per request resolver, keep lazy view resolution typed to its Factory contract, and express `UrlWindow`'s structural property only at its read site. | +| `pagination-10`–`pagination-12` | JSON, type, and reset defects | Minor | High | Throw `JsonException` in one encoding pass, restore current conditional/iterator types, and reset all seven worker-static configuration slots to shared defaults. | +| `pagination-13`, `pagination-15` | Public differences and package hygiene | Minor | High | Document Tailwind-only views and `current_page_url`, and complete metadata, docs, strict comparison, and test typing. | +| `pagination-14` | Current Laravel runtime parity | Major | High | Port zero-per-page safety and unconditional cursor-key normalization. | +| `collections-15`, `support-32`, `sanctum-02`, `api-client-01` | JSON failure defects | Minor | High | Apply the same one-pass throwing JSON boundary at each owning package while preserving caller flags and delegated pretty output. | +| `support-33`, `routing-25` | Current Laravel type parity | Minor | High | Port Lottery's conditional result and Route's conditional domain type with complete current type fixtures. | +| `database-24` | Cursor pagination contract defect | Major | High | Accept cursor objects in both relation families and restore the concrete Eloquent cursor result. | +| `database-25` | Paginator type-contract defect | Minor | High | Restore Eloquent paginator generics and make Query's cursor per-page boundary truthfully non-null. | +| `scout-41` | Search pagination defect | Major | High | Clamp all four public Scout page producers before any first-party or third-party engine sees zero or a negative page. | + +- **Carried findings:** `pagination-01` and `pagination-02` are revalidated across every supported cursor item shape: missing/null order values still fail, and pivot values retain their truthful mixed domain. +- **Architecture and worker ownership:** Paginator instances remain operation-local. Cursor construction owns the flat-parameter invariant across decoded, direct, generated, and userland cursors; malformed decoded arrays retain the existing null result. Three paginator request resolvers and the cursor resolver read only the current `RequestContext`; the lazy view resolver alone captures the worker container and honors rebinding. Those five resolvers and two view names remain explicit worker-static boot/test configuration reset by the existing subscriber. No paginator service, request context slot, lock, registry, or retained request object was added. +- **Important rejected concerns:** Do not add cursor signing/encryption, strict base64 mode, recursive payload validation, a generic query-bindability validator, a JSON helper or wrapper exception, a generic reset framework, eager view resolution, broader View/Factory contracts, an `onEachSide` interface/property promise, concrete `UrlWindow` dependency, global PHPStan ignore, uniform per-page normalization, or Scout paginator generics unsupported by engine contracts. Arrays are the only cursor parameter shape that the query builder silently changes; enums and dates are valid bindings, while unsupported objects already fail loudly. A local `UrlWindow` intersection is required because changing the public contract breaks Laravel compatibility and interface mocks, while narrowing the stored property rejects valid assignments at higher analysis levels. +- **Upstream inventory and owner routes:** #59699 contributes the malformed-cursor source/tests; #60968 contributes zero-per-page and unconditional cursor-item indexing; all 18 files from #60586 were checked against current source. Collections random types and Validation defaults were already current; Pagination, Support Lottery, and Routing land here. View's `ComponentAttributeBag::data()` type is complete in the merged View audit, while Translation's `__()` conditional type remains routed to its active owner. +- **Cross-package revalidation:** Completed Collections, Support, Database, Routing, and Scout are revalidated by `collections-15`, `support-32`, `support-33`, `database-24`, `database-25`, `routing-25`, and `scout-41`. Sanctum and API Client receive only `sanctum-02` and `api-client-01`; their later full package audits remain open. No consumer-local workaround or duplicate normalization was added. +- **Regression and static coverage:** Counterfactual tests cover raw and encoded malformed cursors, every invalid envelope shape, flat/nested/empty decoded arrays, direct array construction, generation from an array-valued ordered item, mixed cursor and pivot values, backed enums, float-ordered SQLite paging, integer query keys, explicit zero across direct/Query/Eloquent paths, Scout engine inputs, one-read request ownership, concurrent isolation, lazy view rebinding, JSON failures and escape flags, keyed cursor pages, zero per-page, every static slot, provider discovery, relation cursor objects, concrete/generic paginator results, and conditional/iterator types. `src` analysis pins the local `UrlWindow` structural read; focused type fixtures cover Pagination, Database builders/relations, Routing, and Lottery. +- **Performance and compatibility:** Four request resolvers remove a duplicate context check and container/request resolution. Added recurring work is one allocation-free, non-recursive scan of the typically one-to-three values at Cursor construction, one required `Collection::values()` normalization per cursor page measured below a microsecond at representative sizes, one integer `max()` per Scout pagination call, and one negligible lazy-resolver return verification. JSON and URL serialization remain one pass; no query, network round trip, lock, yield, retry, polling loop, unbounded cache, or retained worker memory is added. Laravel APIs are preserved or widened except that array-valued cursor parameters now fail before the query builder can silently change them, invalid explicit pages now fail closed before ambient resolver or engine behavior, and Query Builder's cursor per-page parameter is narrowed from nullable to its already-required `int`; explicit null already threw one frame deeper, so valid calls and runtime work are unchanged while the failure moves to the call site. +- **Validation and review:** Every changed test file and affected max-level type fixture is green. The authoritative `composer fix` gate passed formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, and dogfood; post-gate review corrections passed focused tests, scoped formatting/analysis, Composer validation, and `git diff --check`. Independent review re-read every changed file, traced all callers, verified retained regressions and hot paths, and signed off after the final bounded corrections. +- **Assessment:** Pagination is current, coroutine-safe, malformed-envelope-safe, structured-parameter-safe, integer-key-correct, and truthful across direct, database, relation, and search paths. Every accepted finding is fixed at its lowest owner without speculative machinery, stale code, meaningful unaccounted performance regression, unintended Laravel API break, or unresolved accepted defect. + ### Complete View correctness, lifecycle, and current parity - **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh self-review, and independent code review—including the compile-pass failure cleanup follow-up—are signed off. The audit covered every View source and test file; Foundation configuration and `view:cache`; Support facade metadata; Mail's Markdown consumer; Boost documentation; split metadata; current Laravel View source, tests, documentation, and full-app fixtures; and carried `view-01` and `reflection-02`. The detailed design is recorded in [`2026-08-06-0916-view-correctness-lifecycle-and-current-parity.md`](2026-08-06-0916-view-correctness-lifecycle-and-current-parity.md). diff --git a/docs/plans/2026-08-06-0928-pagination-correctness-current-parity-and-query-contracts.md b/docs/plans/2026-08-06-0928-pagination-correctness-current-parity-and-query-contracts.md new file mode 100644 index 000000000..21dfd09db --- /dev/null +++ b/docs/plans/2026-08-06-0928-pagination-correctness-current-parity-and-query-contracts.md @@ -0,0 +1,658 @@ +# Complete Pagination correctness, current parity, and query contracts + +## Objective + +Correct malformed cursor handling, structured cursor parameters, truthful cursor/query value +types, explicit page resolution, JSON failures, paginator reset coverage, and current Laravel +paginator parity. Keep paginator instances operation-local, the seven configuration slots +worker-static, and request values owned by `RequestContext`. Preserve Laravel APIs unless a +verified defect or Hypervel's coroutine model requires the approved correction. + +Hypervel 0.4 is greenfield: churn and compatibility with prior Hypervel behavior do not justify +retaining flawed code. Current Laravel APIs, named arguments, extension points, and documented +behavior remain compatible except for the approved invalid-page correction: explicit zero no +longer consults ambient state, and Scout no longer forwards zero or negative pages to engines. + +## Evidence baseline + +- Hypervel baseline: `28769ce5a` on `audit/pagination-correctness-parity`. +- Current Laravel source: local `examples/laravel/framework` `13.x` at `1a7816b370`. +- Historical changes were used for discovery; current Laravel is the implementation reference: + - #59699 (`5937afc`): `Cursor` and `CursorTest` malformed-payload handling. + - #60586 (`4eef2ca`): 18 files across Collections, Pagination contracts/abstracts, Foundation, + Routing, Support, Validation, View, and type fixtures. + - #60968 (`dc1b82b`): `CursorPaginator`, `LengthAwarePaginator`, and their tests. +- Hypervel history confirms the relevant owner decisions: `3af18f750` widened ordinary append + values, `c0befa549` removed Bootstrap selectors, `12fc9ceba` added length-aware + `current_page_url`, and `3cf34fbec` widened Eloquent's cursor result only to silence PHPStan. +- Hypervel's two Tailwind views are byte-identical to current Laravel. +- Existing ledger findings `pagination-01` and `pagination-02` own missing cursor-order values and + mixed pivot values. Revalidate them; new Pagination findings begin at `pagination-03`. +- Probes reproduced non-string/malformed cursors, silently flattened structured cursor values, + float cursor failures, numeric query-key renumbering, explicit page-zero resolver fallback, + invalid UTF-8 `TypeError`s, a zero-per-page length-aware result, keyed cursor items, relation + cursor type rejection, and the `UrlWindow` contract/mockability boundary. + +## Anti-overengineering rules + +The following wording is retained verbatim from the core audit plan. Its principle numbering is +also retained; principles 1–6 remain in the core operating plan. In principle 9, “later in this +plan” refers to that plan's **Established remediation vocabulary** section. + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Architecture and retained boundaries + +- `Paginator`, `LengthAwarePaginator`, and `CursorPaginator` are fresh operation-local values. + Their query, page/cursor, path, fragment, options, and items need no service binding, context + slot, lock, or teardown. +- Four resolvers, two default view names, and the cursor resolver are boot-owned worker-static + configuration. Public mutators remain boot/test only; `AfterEachTestSubscriber` remains the + sole test reset owner. +- Request-facing resolver closures remain worker-static but read the current request from + `RequestContext` on each invocation. They never capture a request. +- The view factory remains lazily resolved from the canonical `view` service so application and + test rebindings work. +- URL/query serialization remains a single `Arr::query()` pass; JSON serialization remains one + native `json_encode()` pass. +- Relation and builder corrections state existing runtime behavior; they do not add wrappers or + change query execution. + +## Findings and final decisions + +| ID | Category | Severity | Confidence | Final decision | +|---|---|---:|---:|---| +| `pagination-01` | Cursor-order correctness | Major | High | Revalidate the completed null/missing-order-value rejection. | +| `pagination-02` | Pivot cursor typing | Major | High | Revalidate the completed mixed pivot-value return. | +| `pagination-03` | Malformed cursor defect/parity | Major | High | Return `null` for non-string, undecodable, non-array, incomplete, or non-boolean-direction cursor payloads. | +| `pagination-04` | Cursor value contract | Major | High | Return mixed cursor parameters and preserve bool/float database order values. | +| `pagination-05` | Query append contract | Major | High | Accept Laravel-supported query values and preserve integer keys without renumbering. | +| `pagination-06` | Explicit page defect | Minor | High | Treat only `null` as omitted at page-based boundaries; normalize Scout pages to at least one before engine dispatch. | +| `pagination-07` | Resolver hot-path improvement | Minor | High | Read `RequestContext` once per request-facing resolver and remove container request resolution. | +| `pagination-08` | View contract | Minor | High | Return the existing View Factory contract without changing lazy resolution. | +| `pagination-09` | `UrlWindow` type contract | Minor | High | Replace the broad suppression with a local contract-and-structural intersection; keep Laravel's public contract mockable. | +| `pagination-10` | JSON failure semantics | Minor | High | Use one throwing JSON encode at all four Pagination-owned boundaries. | +| `pagination-11` | Current Laravel type parity | Minor | High | Port conditional fragment and precise iterator types from current source. | +| `pagination-12` | Static reset integrity | Minor | High | Share Tailwind default constants and pin all seven static slots. | +| `pagination-13` | Intentional/public differences | Minor | High | Record Tailwind-only views and `current_page_url`; keep both approved behaviors. | +| `pagination-14` | Current Laravel runtime parity | Major | High | Port zero-per-page last-page safety and unconditional cursor item reindexing. | +| `pagination-15` | Package metadata/hygiene | Minor | High | Complete root provider discovery, option docs, strict comparison, guide grammar, and test typing. | +| `pagination-16` | Structured cursor parameter defect | Major | High | Reject array-valued parameters when constructing a Cursor while classifying decoded arrays as malformed input. | +| `collections-15` | JSON failure semantics | Minor | High | Make `EnumeratesValues::toJson()` throw `JsonException`. | +| `support-32` | JSON failure semantics | Minor | High | Make `Fluent` and `MessageBag` JSON boundaries throw `JsonException`. | +| `support-33` | Current Laravel type parity | Minor | High | Port `Lottery::choose()` conditional PHPDoc. | +| `sanctum-02` | JSON failure semantics | Minor | High | Make `NewAccessToken::toJson()` throw `JsonException`. | +| `api-client-01` | JSON failure semantics | Minor | High | Make `ApiResource::toJson()` throw `JsonException`; do not expand into the deferred package audit. | +| `database-24` | Cursor pagination contracts | Major | High | Accept cursor objects in both relation families and restore concrete Eloquent cursor results. | +| `database-25` | Paginator type contracts | Minor | High | Restore all three Eloquent result generics and make Query cursor per-page nullability truthful. | +| `scout-41` | Search pagination defect | Major | High | Clamp all four public Scout page producers to page one before any engine sees zero or a negative page. | +| `routing-25` | Current Laravel type parity | Minor | High | Add `Route::domain()`'s conditional get/set PHPDoc and the missing current Routing type fixture. | + +View's `ComponentAttributeBag::data()` type is complete in its owning audit. Translation's `__()` +conditional type remains routed to its active owning work. Record the complete #60586 file-set +review and durable route in the ledger/dependency index. + +## Implementation + +### 1. Classify malformed cursors and structured parameters at the boundary + +Reject arrays once at the constructor so decoded, direct, generated, and userland cursors share +the same invariant: + +```php +public function __construct( + protected array $parameters, + protected bool $pointsToNextItems = true, +) { + foreach ($parameters as $parameterName => $parameter) { + if (is_array($parameter)) { + throw new InvalidArgumentException("Cursor parameter [{$parameterName}] must not be an array."); + } + } +} +``` + +Arrays uniquely became silent query corruption after `parameter()` widened to `mixed`: +`Query\Builder::flattenValue()` binds their first nested scalar. Query-bindable enums and dates +must remain valid, while other unsupported objects still fail loudly downstream. Do not replace +the exact array check with a speculative bindability validator. + +Widen only the decoder input, validate its envelope, and translate the constructor's exact +invalid-parameter failure into the existing null-on-malformed result: + +```php +public static function fromEncoded(mixed $encodedString): ?static +{ + if (! is_string($encodedString)) { + return null; + } + + $parameters = json_decode( + base64_decode(str_replace(['-', '_'], ['+', '/'], $encodedString)), + true, + ); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + // Validate the complete envelope here so strict construction cannot raise a TypeError for its direction. + if (! is_array($parameters) + || ! array_key_exists('_pointsToNextItems', $parameters) + || ! is_bool($parameters['_pointsToNextItems'])) { + return null; + } + + $pointsToNextItems = $parameters['_pointsToNextItems']; + unset($parameters['_pointsToNextItems']); + + try { + return new static($parameters, $pointsToNextItems); + } catch (InvalidArgumentException) { + return null; + } +} +``` + +Keep lenient base64 decoding. Do not add signing, encryption, recursive schema validation, or +downstream exception swallowing. Keep constructor/map docs at `array`. Test all +current upstream malformed shapes, Hypervel's additional non-boolean direction, flat/nested/empty +array parameters, direct construction, generation from an array-valued ordered attribute, a +backed-enum round trip, and `/?cursor[]=x` through the request resolver. + +### 2. Preserve truthful cursor and query values + +`Cursor::parameter()` returns `mixed`; `parameters()` documents `array`. The constructor +rejects only arrays, preserving bool, float, enum, date, and other query-bindable values. Keep +`getPivotParameterForItem(): mixed` from `pagination-02` and pin both bool/float unit values and a +real SQLite second-page float-order query. + +Align both abstract paginator implementations and both public contracts: + +```php +public function appends( + array|int|string|null $key, + array|bool|float|int|string|null $value = null, +): static; + +protected function addQuery(int|string $key, mixed $value): static; +``` + +Document stored query maps as `array`. In both URL builders, overlay caller +parameters without renumbering nonzero integer keys: + +```php +$parameters = array_replace($this->query, $parameters); +``` + +Test nonzero/nonsequential integer keys plus scalar and nested-array values. Do not advertise +objects or resources. + +### 3. Distinguish omitted pages from explicit zero + +In both paginator `setCurrentPage()` methods, distinguish explicit zero from omission with the +method's existing variable and resolver shape: + +```php +// Paginator +$currentPage = $currentPage ?? static::resolveCurrentPage(); + +// LengthAwarePaginator +$currentPage = $currentPage ?? static::resolveCurrentPage($pageName); +``` + +At the four Query/Eloquent producer sites, use null coalescing before their already-clamped SQL +offset calculation: + +```php +$page = $page ?? Paginator::resolveCurrentPage($pageName); +``` + +At Scout's four public page producers, normalize once before dispatch to every first-party or +third-party engine: + +```php +$page = max(1, $page ?? Paginator::resolveCurrentPage($pageName)); +``` + +This closes the existing negative-page hole and prevents explicit zero reaching Algolia as +`page => -1`. Query/Eloquent `offset()` and the database-backed Scout engines already clamp their +arithmetic; `TypesenseEngine` also clamps, while Meilisearch only dropped zero incidentally and +Algolia did not clamp. The shared Scout boundary is the lowest complete owner. + +Leave every `$perPage ?: ...` expression unchanged. Eloquent/Scout intentionally treat zero as +“use the model/default value”; Query Builder preserves Laravel's caller-supplied zero and +`LengthAwarePaginator` handles it safely in step 8. Test a non-one ambient resolver across direct, +Query, Eloquent, and Scout producers, plus explicit zero and negative pages through Scout's raw +engine path so database-side clamping cannot make the regression vacuous. + +### 4. Remove duplicate request resolution and type rendering + +Each request resolver reads context once and uses its existing no-request default: + +```php +static function (string $pageName = 'page'): int { + $request = RequestContext::getOrNull(); + + if ($request === null) { + return 1; + } + + $page = $request->input($pageName); + + return filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1 + ? (int) $page + : 1; +} +``` + +Use the equivalent direct logic for path (`'/'`), query (`[]`), and cursor (`null`). Do not call +`has()`, resolve `$app['request']`, or capture `$app` in request closures. Static closures merely +signal no binding; the performance gain comes from removing the second context lookup and +container request resolution. + +Keep the view resolver lazy and resolve `$app->make('view')`. Type both `viewFactory()` methods +and their resolver as `Hypervel\Contracts\View\Factory`. Laravel's factory contract returns +`View`, while paginator `render()` promises `Htmlable`; the concrete framework view implements +both. State that local invariant immediately above each of the three render returns as +`Htmlable&View`. Do not widen either public contract, add a runtime guard, or return the concrete +factory. The View contract also serves plain-text mail and TypeScript generation, so making every +view `Htmlable` would be semantically false as well as Laravel-incompatible. Tests pin +request/no-request behavior, container-request rebinding not overriding `RequestContext`, +coroutine isolation, and lazy view factory rebinding. Document the request-binding difference in +the Pagination README only. + +Rewrite `PaginationState::resolveUsing()`'s method warning so it states the final ownership: +the four request resolvers read `RequestContext` without capturing the container, while only the +worker-static lazy view resolver captures it. Remove the stale claim that all request resolvers +capture `$app`. + +### 5. State `UrlWindow`'s local structural requirement + +Do not add a native property to Laravel's public contract. At the only consumer: + +```php +/** + * The Laravel contract omits the readable window-size property required by UrlWindow. Keep the + * structural expectation local so the public contract remains compatible and mockable. + * + * @var object{onEachSide: int}&PaginatorContract $paginator + */ +$paginator = $this->paginator; + +$onEachSide = $paginator->onEachSide; +``` + +Use the concise repository-style WHY form during implementation; both intersection halves are +load-bearing. Remove the bare `property.notFound` suppression. + +Record the rejected interface-property alternative and its evidence: + +1. current Laravel does not declare `onEachSide`, so Hypervel would make the public contract + stricter; +2. pinned Mockery cannot mock an interface with the abstract property (`contains 1 abstract + method`), although concrete paginator mocks work; +3. runtime failure requires a custom implementation that also reaches `UrlWindow`, supporting + Minor severity; and +4. narrowing the stored property PHPDoc instead of the local read rejects normal + `LengthAwarePaginator` assignments at higher analysis levels. + +This disposition is complete, not open. No README difference is created because the public +contract stays Laravel-shaped. + +### 6. Make strict JSON boundaries fail with `JsonException` + +Use one encoding pass and preserve caller flags: + +```php +return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); +``` + +`Cursor::encode()` has no option parameter, so use `JSON_THROW_ON_ERROR` directly before its +existing base64 transformation. Add `@throws JsonException` and invalid UTF-8 regressions at all +nine boundaries across five package owners: + +- Pagination: `Cursor`, `Paginator`, `LengthAwarePaginator`, `CursorPaginator`; +- Collections: `EnumeratesValues`; +- Support: `Fluent`, `MessageBag`; +- Sanctum: `NewAccessToken`; +- API Client: `ApiResource`. + +Keep `JSON_PARTIAL_OUTPUT_ON_ERROR` and `JSON_INVALID_UTF8_SUBSTITUTE` effective when callers pass +them. Do not add a helper, recursive preflight, wrapper exception, or second encode. + +Document the propagated exception on the six delegating `toPrettyJson()` methods in +`Paginator`, `LengthAwarePaginator`, `CursorPaginator`, `EnumeratesValues`, `Fluent`, and +`MessageBag`, and on `Enumerable::toJson()` / `Enumerable::toPrettyJson()`. Do not annotate the +shared `Jsonable` contract: Model and JSON Resource implementations translate `JsonException` +into `JsonEncodingException`, so that contract cannot truthfully promise one exception family. + +### 7. Port current static types without changing runtime behavior + +Replace the existing fragment union docs on both contracts and both abstracts with current +conditional return types: + +```php +/** @return ($fragment is null ? null|string : $this) */ +public function fragment(?string $fragment = null); +``` + +Use current `ArrayIterator`/`Traversable` docs and assertions exactly, preserving stronger native +types. Port `Lottery::choose()`'s current conditional PHPDoc and add `types/Support/Lottery.php` +to pin both branches; no upstream fixture exists. Record View's completed +`ComponentAttributeBag::data()` correction, route Translation's `__()` conditional type to its +active owning work, and record the complete #60586 file inventory so neither is lost. + +Add the one remaining unowned source correction from that inventory: + +```php +/** + * Get or set the domain for the route. + * + * @return ($domain is null ? null|string : $this) + */ +``` + +Record the file-set result, not just its names: Collections `Arr::random()`, +`Enumerable::random()`, and `LazyCollection::random()` are already current; Routing +`Route::getMetadata()` is current while `Route::domain()` needs the line above; Validation's +Email/File/Password defaults are already truthfully stronger; Hypervel's real mapped fixtures +`types/Collections/Arr.php` and `types/Collections/Collection.php` already pin the two Support +type files; Pagination contracts, abstracts, and type fixture plus Support Lottery land here; +View's `ComponentAttributeBag::data()` correction is complete, and Translation remains the +durable active-owner route. + +Hypervel has no Routing type fixture. Create `types/Routing/Route.php` by porting the complete +current seven-assertion upstream file rather than only the historical PR additions. Add the +repository-standard `declare(strict_types=1);` header and Hypervel imports, matching sibling type +fixtures: + +```php +assertType('array', RouteFacade::get('/')->middleware()); +assertType(Route::class, RouteFacade::get('/')->middleware('auth')); +assertType(Route::class, RouteFacade::get('/')->middleware(['auth'])); + +assertType('string|null', RouteFacade::get('/')->domain()); +assertType(Route::class, RouteFacade::get('/')->domain('example.com')); + +assertType('array', RouteFacade::get('/')->getMetadata()); +assertType('mixed', RouteFacade::get('/')->getMetadata('key')); +``` + +The four domain/metadata assertions pin #60586; the three middleware assertions keep the new +fixture equal to current Laravel instead of creating a partial test file. + +For `AbstractCursorPaginator::setCollection()`, keep only the line-scoped assignment suppression +and explain that assignment precedes `@phpstan-this-out` rebinding the receiver from +`TKey/TValue` to `TSetKey/TSetValue`. `AbstractPaginator` takes the existing templates and needs no +suppression. Add no cast, runtime branch, or global PHPStan ignore. + +### 8. Port current runtime fixes from Laravel #60968 + +Avoid division by zero while preserving caller-supplied `perPage`: + +```php +$this->lastPage = max((int) ceil($total / max($this->perPage, 1)), 1); +``` + +For `total: 4, perPage: 0`, the public values remain `perPage = 0`, `lastPage = 4`. This is the +current Laravel contract; do not invent shared per-page normalization. + +Normalize cursor page keys after forward/previous handling: + +```php +if (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()) { + $this->items = $this->items->reverse(); +} + +$this->items = $this->items->values(); +``` + +Test keyed forward and previous collections. The unconditional `values()` cost was measured at +roughly 0.2–0.5 microseconds for normal page sizes; it is the only recurring added work and is +required by the public indexed-item invariant. Reject a conditional reindex branch. + +### 9. Restore builder and relation cursor contracts + +Both relation families accept the same cursor forms as the forwarded builder: + +```php +public function cursorPaginate( + ?int $perPage = null, + array $columns = ['*'], + string $cursorName = 'cursor', + Cursor|string|null $cursor = null, +): mixed; +``` + +Keep Laravel's native `mixed` return surface on all six relation pagination methods, with precise +generic PHPDocs and max-level type assertions. Import `Cursor` in each relation. + +Restore the concrete Eloquent result by correcting the owning trait: + +```php +protected function paginateUsingCursor( + int $perPage, + array|string $columns = ['*'], + string $cursorName = 'cursor', + Cursor|string|null $cursor = null, +): CursorPaginator; +``` + +Drop its redundant return PHPDoc and do not add generics to the Query/Eloquent shared trait. +Add current Laravel's local generic return PHPDocs to all three public Eloquent Builder methods: +`LengthAwarePaginator`, `Paginator`, and +`CursorPaginator`. `Eloquent\Builder::cursorPaginate()` returns concrete +`Hypervel\Pagination\CursorPaginator`; Query Builder keeps the three existing bare public +paginator results. Make `Query\Builder::cursorPaginate()`'s `$perPage` an `int`: the shared +callee already requires one, explicit null currently fatals inside the trait, and no caller uses +null. Pin that native contract with the repository's bounded reflection-test pattern because +level-five source analysis does not detect a nullable-to-non-nullable hop; do not couple the test +to PHP's TypeError wording. + +Type assertions pin both relation families, all three Eloquent and Query Builder results, +`Model::query()->cursorPaginate()->toJson()`, the two corrected `ArrayIterator` results, and +current Laravel's four Paginator/CursorPaginator fragment results. Leave Scout's four results +bare: raw variants contain engine payloads, model variants depend on non-generic engine +contracts, and current upstream Scout deliberately retains the same surface. + +### 10. Make worker-static reset exhaustive but explicit + +Define protected defaults and reuse them for initialization, `useTailwind()`, and `flushState()`: + +```php +protected const DEFAULT_VIEW = 'pagination::tailwind'; +protected const DEFAULT_SIMPLE_VIEW = 'pagination::simple-tailwind'; +``` + +Add one bounded reflection regression for the six `AbstractPaginator` slots and one +`AbstractCursorPaginator` slot. Mutate through public APIs, flush both classes, and assert the +exact defaults. Production reset remains explicit; add no registry or generic reset framework. + +### 11. Record real public differences and complete package metadata + +Keep Bootstrap selector methods/views removed. Current Laravel tests have no matching selectors, +so add no synthetic `REMOVED:` test marker. Add one source comment at the natural insertion point +after `useTailwind()` and a README `Differences From Laravel` entry explaining that Hypervel ships +Tailwind views only and that `Paginator::defaultView()` / `defaultSimpleView()` configure custom +views. + +Keep `LengthAwarePaginator`'s Hypervel-owned `current_page_url` JSON field and document it as a +public difference. The Boost guide already demonstrates the field; do not duplicate usage prose. + +Final README order: + +1. package header/badge; +2. `Documentation: https://hypervel.org/docs/pagination`; +3. approved `Differences From Laravel`; +4. `Ported from: https://github.com/laravel/framework`. + +Add `PaginationServiceProvider` to root `extra.hypervel.providers`; split metadata and +`DefaultProviders` already contain it. Add `tests/Pagination/PackageMetadataTest.php` to pin root +and split declarations. + +Bring `src/database/README.md` into the repository README order: add the existing Database guide +link, retain its approved differences, and move the bare Laravel upstream line to the end. The +corrected concrete Eloquent cursor result is parity, so do not invent a difference entry for it. + +Correct `CursorPaginator` option docs to `cursorName`/`parameters`, use strict `!==` in +`hasPages()`, fix the guide's article grammar, add `: void` to all 78 untyped Pagination test +methods, and type `CursorPaginatorTest::getCursor(array $params, bool $isNext = true): string`. +Do not type constructors or change already-correct helpers. + +## Test plan + +### Pagination unit/package tests + +- `CursorTest`: malformed encoding plus scalar/array/direction payloads, direct and generated + structured-parameter rejection, bool/float/backed-enum values, throwing JSON, and escape-hatch + JSON flags where applicable. +- `PaginationResolverTest`: request array cursor, no-context defaults, exact one-context ownership, + container request rebinding, view rebinding, and existing concurrent isolation. +- Abstract/concrete paginator tests: append value families, integer-key preservation, explicit + zero, fragment/iterator assertions, JSON exceptions, keyed cursor normalization, zero per-page, + strict `hasPages()`, and all seven static reset slots. +- `UrlWindowTest` keeps existing runtime behavior covered; the `src` analysis gate pins the local + intersection. +- `PackageMetadataTest`: root/split provider discovery. + +### Cross-package tests + +- Existing SQLite `EloquentCursorPaginateTest`: float second page. +- Query/Eloquent producer tests: explicit page zero with a conflicting resolver. Scout producer + tests: zero and negative pages through the raw-engine path, plus null resolver behavior. +- Relation runtime/type fixtures: `Cursor` object inputs and six relation return types. +- Eloquent/Query Builder type fixtures: three result surfaces each, concrete cursor JSON access, + and the non-null Query cursor per-page reflection contract. +- Collections, Support, Sanctum, and API Client owner tests: invalid UTF-8, successful output, + caller option precedence, and delegated `toPrettyJson()` exceptions where the owner exposes it. +- Run max-level type fixtures for Pagination, Routing, Support Lottery, Eloquent Builder, Query + Builder, and both relation families. + +### Verification cadence + +Edit and test one file/class at a time. Run changed test files immediately, then affected focused +package/type suites. At the implementation checkpoint run `composer fix` once. After self-review +and review corrections, rerun focused tests and repeat `composer fix` only if warranted. + +## Performance and compatibility budget + +- Removed work: four request resolvers lose one context check and one container/request + resolution per call. +- Unchanged work: URL serialization and every JSON boundary remain one native pass; view factory + resolution remains lazy; its resolver adds one negligible return-type verification before the + existing `viewFactory(): Factory` verification; no locks, yields, retries, registries, scoped + services, or retained request objects are added. +- Added recurring work: one early-exit shallow scan of the typically one-to-three cursor parameters + per Cursor construction, one tiny `Collection::values()` normalization per cursor page measured + in the sub-microsecond range at representative sizes, and one integer `max()` per Scout + pagination call. All enforce public invariants at their lowest shared boundary; the scan allocates + nothing and performs no recursion or I/O. +- Cold/error-only checks: malformed cursor envelope validation, JSON exception construction, and + static reset tests do not affect valid hot paths materially. +- Public API changes are widenings or truthful types, except invalid page handling. That approved + correction eliminates ambient resolver capture for explicit zero and normalizes invalid Scout + pages before third-party engine dispatch. +- Query Builder's cursor per-page type is narrowed from nullable to its already-required `int`; + explicit null already throws, so valid calls and runtime work are unchanged. + +## Rejected concerns + +- No scoped paginator service, resolver registry, per-request registration, lock, cleanup hook, + cursor signing/encryption, strict base64 mode, recursive payload validation, global JSON helper, + JSON domain exception, or generic static-reset framework. +- Do not add a generic query-bindability validator. Arrays are the only parameter shape that + silently changes value; unsupported objects already fail loudly, while enums and dates are + valid bindings. +- Do not restore Bootstrap, narrow nullable path contracts, validate speculative per-page or + `onEachSide` inputs, add `getCursorName()` to contracts, eagerly capture the view factory, or + rewrite small Collection pipelines as loops. +- Do not change `call_user_func()` to direct closure invocation: a microprobe found only benchmark + noise and current Laravel retains the existing shape. +- Do not add a native `onEachSide` interface property, `@property-read`, duplicate getter, concrete + `UrlWindow` dependency, or broad suppression. The local intersection is the smallest complete + fix and preserves Laravel compatibility and Mockery interface mocks. +- Do not make `Contracts\View\View` extend `Htmlable`, narrow `Factory::make()` or + `viewFactory()` to concrete implementations, wrap rendered output, or add runtime type guards. + The three local `Htmlable&View` annotations express the concrete paginator requirement without + changing Laravel-compatible extension points or adding render-path work. +- Do not normalize all per-page zero values. Producer semantics differ deliberately; only the + owning length-aware division boundary needs protection. +- Do not expand the API Client, Foundation, Translation, or View work beyond exact routed family + corrections while their full/active audits retain ownership. +- Do not generify Scout's paginator results: two carry raw engine payloads, while the model paths + would require unsupported generics across every engine contract and implementation. + +## Records and completion + +After implementation and review: + +- add the accepted findings and important rejected alternatives to the core ledger using the IDs + above, including the complete #59699/#60586/#60968 file inventories and current-source pin; +- record `pagination-16`, including direct/generated/decoded constructor ownership, why only arrays + are rejected, and the earlier generation-path failure; +- record the `onEachSide` rejected alternative with all three evidence points, not merely the + residual structural fact; +- revalidate `pagination-01`/`pagination-02` and update their dependency-index rows from “later + full pagination audit” to complete; +- retain View's completed `ComponentAttributeBag::data()` owner record and a durable dependency + route for Translation's `__()` conditional type; +- explicitly revalidate the completed `collections`, `support`, `database`, `routing`, and + `scout` packages and amend their ledger entries for `collections-15`, `support-32`, + `support-33`, `database-24`, `database-25`, `routing-25`, and `scout-41`; +- record `sanctum-02` and `api-client-01` as “targeted correction complete; later full + `sanctum`/`api-client` audit” because those package checklists remain incomplete; +- add an explicit dependency-index row for `scout-41` with `scout` as owner and `pagination` and + `scout` as revalidation targets, plus owner/consumer rows for the other cross-package findings; +- mark the core Pagination checklist complete; and +- leave no stale audit wording, obsolete suppression, duplicate docs, or superseded design. + +Completion requires targeted tests, `composer fix`, a full caller/callee and hot-path self-review, +code review signoff, and a final records review. diff --git a/src/api-client/src/ApiResource.php b/src/api-client/src/ApiResource.php index e47642650..b2c1085d5 100644 --- a/src/api-client/src/ApiResource.php +++ b/src/api-client/src/ApiResource.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Support\Traits\ForwardsCalls; +use JsonException; use JsonSerializable; use Stringable; @@ -107,10 +108,12 @@ public function toArray(): array /** * Convert the resource to its JSON representation. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->jsonSerialize(), $options); + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); } /** diff --git a/src/boost/docs/pagination.md b/src/boost/docs/pagination.md index 01906c2b9..7821c1960 100644 --- a/src/boost/docs/pagination.md +++ b/src/boost/docs/pagination.md @@ -165,7 +165,7 @@ However, cursor pagination has the following limitations: ### Manually Creating a Paginator -Sometimes you may wish to create a pagination instance manually, passing it an array of items that you already have in memory. You may do so by creating either an `Hypervel\Pagination\Paginator`, `Hypervel\Pagination\LengthAwarePaginator` or `Hypervel\Pagination\CursorPaginator` instance, depending on your needs. +Sometimes you may wish to create a pagination instance manually, passing it an array of items that you already have in memory. You may do so by creating either a `Hypervel\Pagination\Paginator`, `Hypervel\Pagination\LengthAwarePaginator` or `Hypervel\Pagination\CursorPaginator` instance, depending on your needs. The `Paginator` and `CursorPaginator` classes do not need to know the total number of items in the result set; however, because of this, these classes do not have methods for retrieving the index of the last page. The `LengthAwarePaginator` accepts almost the same arguments as the `Paginator`; however, it requires a count of the total number of items in the result set. diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index a154bbd26..62814626d 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Support\Jsonable; use InvalidArgumentException; use IteratorAggregate; +use JsonException; use JsonSerializable; use SortDirection; use Traversable; @@ -1123,11 +1124,15 @@ public function jsonSerialize(): mixed; /** * Get the collection of items as JSON. + * + * @throws JsonException */ public function toJson(int $options = 0): string; /** * Get the collection of items as pretty print formatted JSON. + * + * @throws JsonException */ public function toPrettyJson(int $options = 0): string; diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index ee1548d0b..1d842e321 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -14,6 +14,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Enumerable; use Hypervel\Support\HigherOrderCollectionProxy; +use JsonException; use JsonSerializable; use Stringable; use UnexpectedValueException; @@ -942,14 +943,18 @@ public function jsonSerialize(): array /** * Get the collection of items as JSON. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->jsonSerialize(), $options); + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); } /** * Get the collection of items as pretty print formatted JSON. + * + * @throws JsonException */ public function toPrettyJson(int $options = 0): string { diff --git a/src/contracts/src/Pagination/CursorPaginator.php b/src/contracts/src/Pagination/CursorPaginator.php index 72c433c44..5e28a2381 100644 --- a/src/contracts/src/Pagination/CursorPaginator.php +++ b/src/contracts/src/Pagination/CursorPaginator.php @@ -26,12 +26,15 @@ public function url(?Cursor $cursor): string; * * @return $this */ - public function appends(array|string|null $key, ?string $value = null): static; + public function appends( + array|int|string|null $key, + array|bool|float|int|string|null $value = null, + ): static; /** * Get / set the URL fragment to be appended to URLs. * - * @return null|$this|string + * @return ($fragment is null ? null|string : $this) */ public function fragment(?string $fragment = null): static|string|null; diff --git a/src/contracts/src/Pagination/Paginator.php b/src/contracts/src/Pagination/Paginator.php index feaea24c7..f6ffa5f13 100644 --- a/src/contracts/src/Pagination/Paginator.php +++ b/src/contracts/src/Pagination/Paginator.php @@ -25,12 +25,15 @@ public function url(int $page): string; * * @return $this */ - public function appends(array|string|null $key, ?string $value = null): static; + public function appends( + array|int|string|null $key, + array|bool|float|int|string|null $value = null, + ): static; /** * Get / set the URL fragment to be appended to URLs. * - * @return null|$this|string + * @return ($fragment is null ? null|string : $this) */ public function fragment(?string $fragment = null): static|string|null; diff --git a/src/database/README.md b/src/database/README.md index d8cd970c5..f834b8002 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -3,7 +3,7 @@ Database for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/database) -Ported from: https://github.com/laravel/framework +Documentation: https://hypervel.org/docs/database ## Differences From Laravel @@ -12,3 +12,5 @@ Ported from: https://github.com/laravel/framework - Laravel's remaining directly deprecated Database compatibility forwarders are intentionally not ported. Use the current class-keyed factory resolver, schema blueprint and grammar APIs, and correctly named PostgreSQL truncation method instead. - `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files. - `Blueprint::dropForeign()` widens Laravel's method signature with an optional constraint name when columns are supplied, allowing explicitly named foreign keys to be dropped portably across SQLite and the server databases. Custom `Blueprint` subclasses that override this method must accept the optional second argument. + +Ported from: https://github.com/laravel/framework diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 27d05913b..e2639ad1a 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -376,11 +376,13 @@ public function sole(array|string $columns = ['*']) /** * Paginate the given query using a cursor paginator. - * - * @return \Hypervel\Contracts\Pagination\CursorPaginator */ - protected function paginateUsingCursor(int $perPage, array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null) - { + protected function paginateUsingCursor( + int $perPage, + array|string $columns = ['*'], + string $cursorName = 'cursor', + Cursor|string|null $cursor = null, + ): CursorPaginator { if (! $cursor instanceof Cursor) { $cursor = is_string($cursor) ? Cursor::fromEncoded($cursor) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 5960ebd70..92ae925d6 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -9,7 +9,6 @@ use Exception; use Hypervel\Contracts\Database\Eloquent\Builder as BuilderContract; use Hypervel\Contracts\Database\Query\Expression; -use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Database\Concerns\BuildsQueries; use Hypervel\Database\Eloquent\Concerns\QueriesRelationships; @@ -19,6 +18,7 @@ use Hypervel\Database\RecordsNotFoundException; use Hypervel\Database\UniqueConstraintViolationException; use Hypervel\Pagination\Cursor; +use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; use Hypervel\Support\Arr; @@ -996,11 +996,13 @@ public function pluck(Expression|string $column, ?string $key = null): BaseColle /** * Paginate the given query. * + * @return \Hypervel\Pagination\LengthAwarePaginator + * * @throws InvalidArgumentException */ public function paginate(Closure|int|null $perPage = null, array|string $columns = ['*'], string $pageName = 'page', ?int $page = null, Closure|int|null $total = null): LengthAwarePaginator { - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ?? Paginator::resolveCurrentPage($pageName); $total = value($total) ?? $this->toBase()->getCountForPagination(); @@ -1018,10 +1020,12 @@ public function paginate(Closure|int|null $perPage = null, array|string $columns /** * Paginate the given query into a simple paginator. + * + * @return \Hypervel\Pagination\Paginator */ public function simplePaginate(?int $perPage = null, array|string $columns = ['*'], string $pageName = 'page', ?int $page = null): Paginator { - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ?? Paginator::resolveCurrentPage($pageName); $perPage = $perPage ?: $this->model->getPerPage(); @@ -1038,8 +1042,10 @@ public function simplePaginate(?int $perPage = null, array|string $columns = ['* /** * Paginate the given query into a cursor paginator. + * + * @return \Hypervel\Pagination\CursorPaginator */ - public function cursorPaginate(?int $perPage = null, array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null): CursorPaginatorContract + public function cursorPaginate(?int $perPage = null, array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null): CursorPaginator { $perPage = $perPage ?: $this->model->getPerPage(); diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 810de6222..0918398f7 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -15,6 +15,7 @@ use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\UniqueConstraintViolationException; +use Hypervel\Pagination\Cursor; use Hypervel\Support\Arr; use Hypervel\Support\Collection as BaseCollection; use Hypervel\Support\StrCache; @@ -860,7 +861,7 @@ public function get(array $columns = ['*']): BaseCollection */ protected function shouldSelect(array $columns = ['*']): array { - if ($columns == ['*']) { + if ($columns === ['*']) { $columns = [$this->related->qualifyColumn('*')]; } @@ -917,8 +918,12 @@ public function simplePaginate(?int $perPage = null, array $columns = ['*'], str * * @return \Hypervel\Contracts\Pagination\CursorPaginator */ - public function cursorPaginate(?int $perPage = null, array $columns = ['*'], string $cursorName = 'cursor', ?string $cursor = null): mixed - { + public function cursorPaginate( + ?int $perPage = null, + array $columns = ['*'], + string $cursorName = 'cursor', + Cursor|string|null $cursor = null, + ): mixed { $this->query->addSelect($this->shouldSelect($columns)); return tap($this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor), function ($paginator) { diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 5e85692c1..972f5e61e 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -13,6 +13,7 @@ use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\UniqueConstraintViolationException; +use Hypervel\Pagination\Cursor; use Hypervel\Support\Arr; use Hypervel\Support\Collection as BaseCollection; @@ -438,7 +439,7 @@ public function get(array $columns = ['*']): BaseCollection /** * Get a paginator for the "select" statement. * - * @return \Hypervel\Pagination\LengthAwarePaginator + * @return \Hypervel\Pagination\LengthAwarePaginator */ public function paginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null): mixed { @@ -450,7 +451,7 @@ public function paginate(?int $perPage = null, array $columns = ['*'], string $p /** * Paginate the given query into a simple paginator. * - * @return \Hypervel\Contracts\Pagination\Paginator + * @return \Hypervel\Contracts\Pagination\Paginator */ public function simplePaginate(?int $perPage = null, array $columns = ['*'], string $pageName = 'page', ?int $page = null): mixed { @@ -462,10 +463,14 @@ public function simplePaginate(?int $perPage = null, array $columns = ['*'], str /** * Paginate the given query into a cursor paginator. * - * @return \Hypervel\Contracts\Pagination\CursorPaginator + * @return \Hypervel\Contracts\Pagination\CursorPaginator */ - public function cursorPaginate(?int $perPage = null, array $columns = ['*'], string $cursorName = 'cursor', ?string $cursor = null): mixed - { + public function cursorPaginate( + ?int $perPage = null, + array $columns = ['*'], + string $cursorName = 'cursor', + Cursor|string|null $cursor = null, + ): mixed { $this->query->addSelect($this->shouldSelect($columns)); return $this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor); @@ -476,7 +481,7 @@ public function cursorPaginate(?int $perPage = null, array $columns = ['*'], str */ protected function shouldSelect(array $columns = ['*']): array { - if ($columns == ['*']) { + if ($columns === ['*']) { $columns = [$this->related->qualifyColumn('*')]; } diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 1c89784c0..4c8fc9157 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -2941,7 +2941,7 @@ public function paginate( ?int $page = null, Closure|int|null $total = null, ): LengthAwarePaginator { - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ?? Paginator::resolveCurrentPage($pageName); $total = value($total) ?? $this->getCountForPagination(); @@ -2968,7 +2968,7 @@ public function simplePaginate( string $pageName = 'page', ?int $page = null, ): PaginatorContract { - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = $page ?? Paginator::resolveCurrentPage($pageName); $this->offset(($page - 1) * $perPage)->limit($perPage + 1); @@ -2986,7 +2986,7 @@ public function simplePaginate( * @param array|ExpressionContract|string $columns */ public function cursorPaginate( - ?int $perPage = 15, + int $perPage = 15, ExpressionContract|array|string $columns = ['*'], string $cursorName = 'cursor', Cursor|string|null $cursor = null, diff --git a/src/pagination/README.md b/src/pagination/README.md index b216357f6..932521fa2 100644 --- a/src/pagination/README.md +++ b/src/pagination/README.md @@ -1,4 +1,14 @@ Pagination for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/pagination) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/pagination) + +Documentation: https://hypervel.org/docs/pagination + +## Differences From Laravel + +- Hypervel ships Tailwind pagination views only; Laravel's Bootstrap view selectors are intentionally omitted. Use `Paginator::defaultView()` and `Paginator::defaultSimpleView()` to configure custom pagination views. +- Length-aware paginator JSON includes `current_page_url`. +- Default request resolvers read `RequestContext`; replacing the container's `request` binding does not change pagination state. + +Ported from: https://github.com/laravel/framework diff --git a/src/pagination/src/AbstractCursorPaginator.php b/src/pagination/src/AbstractCursorPaginator.php index 2b4870c92..f80f493a1 100644 --- a/src/pagination/src/AbstractCursorPaginator.php +++ b/src/pagination/src/AbstractCursorPaginator.php @@ -9,6 +9,7 @@ use Closure; use Exception; use Hypervel\Contracts\Support\Htmlable; +use Hypervel\Contracts\View\Factory; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Http\Resources\Json\JsonResource; @@ -66,7 +67,7 @@ abstract public function render(?string $view = null, array $data = []): Htmlabl /** * The query parameters to add to all URLs. * - * @var array + * @var array */ protected array $query = []; @@ -115,7 +116,7 @@ public function url(?Cursor $cursor): string $parameters = is_null($cursor) ? [] : [$this->cursorName => $cursor->encode()]; if (count($this->query) > 0) { - $parameters = array_merge($this->query, $parameters); + $parameters = array_replace($this->query, $parameters); } return $this->path() @@ -272,7 +273,7 @@ protected function ensureParameterIsPrimitive(mixed $parameter): mixed /** * Get / set the URL fragment to be appended to URLs. * - * @return null|$this|string + * @return ($fragment is null ? null|string : $this) */ public function fragment(?string $fragment = null): static|string|null { @@ -290,8 +291,10 @@ public function fragment(?string $fragment = null): static|string|null * * @return $this */ - public function appends(array|string|null $key, ?string $value = null): static - { + public function appends( + array|int|string|null $key, + array|bool|float|int|string|null $value = null, + ): static { if (is_null($key)) { return $this; } @@ -306,7 +309,7 @@ public function appends(array|string|null $key, ?string $value = null): static /** * Add an array of query string values. * - * @param array $keys + * @param array $keys * @return $this */ protected function appendArray(array $keys): static @@ -337,7 +340,7 @@ public function withQueryString(): static * * @return $this */ - protected function addQuery(string $key, mixed $value): static + protected function addQuery(int|string $key, mixed $value): static { if ($key !== $this->cursorName) { $this->query[$key] = $value; @@ -501,7 +504,7 @@ public static function currentCursorResolver(Closure $resolver): void /** * Get an instance of the view factory from the resolver. */ - public static function viewFactory(): mixed + public static function viewFactory(): Factory { return Paginator::viewFactory(); } @@ -571,6 +574,7 @@ public function getCollection(): Collection */ public function setCollection(Collection $collection): static { + // The receiver's templates are rebound by @phpstan-this-out only after this assignment. /* @phpstan-ignore assign.propertyType */ $this->items = $collection; diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php index 11fb63965..62df7e451 100644 --- a/src/pagination/src/AbstractPaginator.php +++ b/src/pagination/src/AbstractPaginator.php @@ -4,9 +4,11 @@ namespace Hypervel\Pagination; +use ArrayIterator; use Closure; use Hypervel\Contracts\Support\CanBeEscapedWhenCastToString; use Hypervel\Contracts\Support\Htmlable; +use Hypervel\Contracts\View\Factory; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Traits\ForwardsCalls; @@ -65,7 +67,7 @@ abstract public function hasMorePages(): bool; /** * The query parameters to add to all URLs. * - * @var array + * @var array */ protected array $query = []; @@ -116,15 +118,25 @@ abstract public function hasMorePages(): bool; */ protected static ?Closure $viewFactoryResolver = null; + /** + * The default pagination view name. + */ + protected const DEFAULT_VIEW = 'pagination::tailwind'; + + /** + * The default simple pagination view name. + */ + protected const DEFAULT_SIMPLE_VIEW = 'pagination::simple-tailwind'; + /** * The default pagination view. */ - public static string $defaultView = 'pagination::tailwind'; + public static string $defaultView = self::DEFAULT_VIEW; /** * The default "simple" pagination view. */ - public static string $defaultSimpleView = 'pagination::simple-tailwind'; + public static string $defaultSimpleView = self::DEFAULT_SIMPLE_VIEW; /** * Determine if the given value is a valid page number. @@ -173,7 +185,7 @@ public function url(int $page): string $parameters = [$this->pageName => $page]; if (count($this->query) > 0) { - $parameters = array_merge($this->query, $parameters); + $parameters = array_replace($this->query, $parameters); } return $this->path() @@ -185,7 +197,7 @@ public function url(int $page): string /** * Get / set the URL fragment to be appended to URLs. * - * @return null|$this|string + * @return ($fragment is null ? null|string : $this) */ public function fragment(?string $fragment = null): static|string|null { @@ -203,8 +215,10 @@ public function fragment(?string $fragment = null): static|string|null * * @return $this */ - public function appends(array|string|null $key, array|string|null $value = null): static - { + public function appends( + array|int|string|null $key, + array|bool|float|int|string|null $value = null, + ): static { if (is_null($key)) { return $this; } @@ -219,7 +233,7 @@ public function appends(array|string|null $key, array|string|null $value = null) /** * Add an array of query string values. * - * @param array $keys + * @param array $keys * @return $this */ protected function appendArray(array $keys): static @@ -250,7 +264,7 @@ public function withQueryString(): static * * @return $this */ - protected function addQuery(string $key, mixed $value): static + protected function addQuery(int|string $key, mixed $value): static { if ($key !== $this->pageName) { $this->query[$key] = $value; @@ -351,7 +365,7 @@ public function perPage(): int */ public function hasPages(): bool { - return $this->currentPage() != 1 || $this->hasMorePages(); + return $this->currentPage() !== 1 || $this->hasMorePages(); } /** @@ -512,7 +526,7 @@ public static function queryStringResolver(Closure $resolver): void /** * Get an instance of the view factory from the resolver. */ - public static function viewFactory(): mixed + public static function viewFactory(): Factory { return call_user_func(static::$viewFactoryResolver); } @@ -558,10 +572,12 @@ public static function defaultSimpleView(string $view): void */ public static function useTailwind(): void { - static::defaultView('pagination::tailwind'); - static::defaultSimpleView('pagination::simple-tailwind'); + static::defaultView(self::DEFAULT_VIEW); + static::defaultSimpleView(self::DEFAULT_SIMPLE_VIEW); } + // Hypervel ships only Tailwind pagination views, so Bootstrap selectors are intentionally omitted. + /** * Flush all static state. */ @@ -571,14 +587,14 @@ public static function flushState(): void static::$currentPageResolver = null; static::$queryStringResolver = null; static::$viewFactoryResolver = null; - static::$defaultView = 'pagination::tailwind'; - static::$defaultSimpleView = 'pagination::simple-tailwind'; + static::$defaultView = self::DEFAULT_VIEW; + static::$defaultSimpleView = self::DEFAULT_SIMPLE_VIEW; } /** * Get an iterator for the items. * - * @return Traversable + * @return ArrayIterator */ public function getIterator(): Traversable { diff --git a/src/pagination/src/Cursor.php b/src/pagination/src/Cursor.php index e840ef4a9..e337d39af 100644 --- a/src/pagination/src/Cursor.php +++ b/src/pagination/src/Cursor.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Support\Collection; +use InvalidArgumentException; +use JsonException; use UnexpectedValueException; /** @implements Arrayable */ @@ -14,13 +16,21 @@ class Cursor implements Arrayable /** * Create a new cursor instance. * - * @param array $parameters the parameters associated with the cursor + * @param array $parameters the parameters associated with the cursor * @param bool $pointsToNextItems determine whether the cursor points to the next or previous set of items + * + * @throws InvalidArgumentException */ public function __construct( protected array $parameters, protected bool $pointsToNextItems = true, ) { + // Query Builder's flattenValue() silently binds only the first nested scalar from an array. + foreach ($parameters as $parameterName => $parameter) { + if (is_array($parameter)) { + throw new InvalidArgumentException("Cursor parameter [{$parameterName}] must not be an array."); + } + } } /** @@ -28,7 +38,7 @@ public function __construct( * * @throws UnexpectedValueException */ - public function parameter(string $parameterName): string|int|null + public function parameter(string $parameterName): mixed { if (! array_key_exists($parameterName, $this->parameters)) { throw new UnexpectedValueException("Unable to find parameter [{$parameterName}] in pagination item."); @@ -41,7 +51,7 @@ public function parameter(string $parameterName): string|int|null * Get the given parameters from the cursor. * * @param array $parameterNames - * @return array + * @return array */ public function parameters(array $parameterNames): array { @@ -80,18 +90,20 @@ public function toArray(): array /** * Get the encoded string representation of the cursor to construct a URL. + * + * @throws JsonException */ public function encode(): string { - return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(json_encode($this->toArray()))); + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(json_encode($this->toArray(), JSON_THROW_ON_ERROR))); } /** * Get a cursor instance from the encoded string representation. */ - public static function fromEncoded(?string $encodedString): ?static + public static function fromEncoded(mixed $encodedString): ?static { - if ($encodedString === null) { + if (! is_string($encodedString)) { return null; } @@ -101,10 +113,21 @@ public static function fromEncoded(?string $encodedString): ?static return null; } + // Validate the complete envelope here so strict construction cannot raise a TypeError for its direction. + if (! is_array($parameters) + || ! array_key_exists('_pointsToNextItems', $parameters) + || ! is_bool($parameters['_pointsToNextItems'])) { + return null; + } + $pointsToNextItems = $parameters['_pointsToNextItems']; unset($parameters['_pointsToNextItems']); - return new static($parameters, $pointsToNextItems); + try { + return new static($parameters, $pointsToNextItems); + } catch (InvalidArgumentException) { + return null; + } } } diff --git a/src/pagination/src/CursorPaginator.php b/src/pagination/src/CursorPaginator.php index 0bcdbe6c2..07de779b2 100644 --- a/src/pagination/src/CursorPaginator.php +++ b/src/pagination/src/CursorPaginator.php @@ -10,8 +10,10 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Htmlable; use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Contracts\View\View; use Hypervel\Support\Collection; use IteratorAggregate; +use JsonException; use JsonSerializable; /** @@ -37,7 +39,7 @@ class CursorPaginator extends AbstractCursorPaginator implements Arrayable, Arra * Create a new paginator instance. * * @param null|Arrayable|Collection|iterable $items - * @param array $options (path, query, fragment, pageName) + * @param array $options (path, query, fragment, cursorName, parameters) */ public function __construct(mixed $items, int $perPage, ?Cursor $cursor = null, array $options = []) { @@ -68,8 +70,10 @@ protected function setItems(mixed $items): void $this->items = $this->items->slice(0, $this->perPage); if (! is_null($this->cursor) && $this->cursor->pointsToPreviousItems()) { - $this->items = $this->items->reverse()->values(); + $this->items = $this->items->reverse(); } + + $this->items = $this->items->values(); } /** @@ -89,6 +93,8 @@ public function links(?string $view = null, array $data = []): Htmlable */ public function render(?string $view = null, array $data = []): Htmlable { + // Laravel's View contract omits Htmlable, but factory-created views fulfill both contracts. + /** @var Htmlable&View */ return static::viewFactory()->make($view ?: Paginator::$defaultSimpleView, array_merge($data, [ 'paginator' => $this, ])); @@ -158,14 +164,18 @@ public function jsonSerialize(): array /** * Convert the object to its JSON representation. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->jsonSerialize(), $options); + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); } /** * Convert the object to pretty print formatted JSON. + * + * @throws JsonException */ public function toPrettyJson(int $options = 0): string { diff --git a/src/pagination/src/LengthAwarePaginator.php b/src/pagination/src/LengthAwarePaginator.php index 7cdba3b00..f974d7420 100644 --- a/src/pagination/src/LengthAwarePaginator.php +++ b/src/pagination/src/LengthAwarePaginator.php @@ -10,8 +10,10 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Htmlable; use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Contracts\View\View; use Hypervel\Support\Collection; use IteratorAggregate; +use JsonException; use JsonSerializable; /** @@ -54,7 +56,7 @@ public function __construct(mixed $items, int $total, int $perPage, ?int $curren $this->total = $total; $this->perPage = $perPage; - $this->lastPage = max((int) ceil($total / $perPage), 1); + $this->lastPage = max((int) ceil($total / max($this->perPage, 1)), 1); $this->path = $this->path !== '/' ? rtrim($this->path, '/') : $this->path; $this->currentPage = $this->setCurrentPage($currentPage, $this->pageName); $this->items = $items instanceof Collection ? $items : new Collection($items); @@ -65,7 +67,7 @@ public function __construct(mixed $items, int $total, int $perPage, ?int $curren */ protected function setCurrentPage(?int $currentPage, string $pageName): int { - $currentPage = $currentPage ?: static::resolveCurrentPage($pageName); + $currentPage = $currentPage ?? static::resolveCurrentPage($pageName); return $this->isValidPageNumber($currentPage) ? (int) $currentPage : 1; } @@ -87,6 +89,8 @@ public function links(?string $view = null, array $data = []): Htmlable */ public function render(?string $view = null, array $data = []): Htmlable { + // Laravel's View contract omits Htmlable, but factory-created views fulfill both contracts. + /** @var Htmlable&View */ return static::viewFactory()->make($view ?: static::$defaultView, array_merge($data, [ 'paginator' => $this, 'elements' => $this->elements(), @@ -218,14 +222,18 @@ public function jsonSerialize(): array /** * Convert the object to its JSON representation. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->jsonSerialize(), $options); + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); } /** * Convert the object to pretty print formatted JSON. + * + * @throws JsonException */ public function toPrettyJson(int $options = 0): string { diff --git a/src/pagination/src/PaginationState.php b/src/pagination/src/PaginationState.php index 49484f454..73e7b1dad 100644 --- a/src/pagination/src/PaginationState.php +++ b/src/pagination/src/PaginationState.php @@ -6,56 +6,52 @@ use Hypervel\Context\RequestContext; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\View\Factory; class PaginationState { /** * Bind the pagination state resolvers using the given application container as a base. * - * Boot-only. Registers worker-lifetime resolvers (path, page, query string, - * cursor) on Paginator/CursorPaginator that capture the application - * container; each resolver itself reads per-request data via RequestContext. + * Boot-only. The request resolvers read RequestContext on each invocation, + * while the lazy view resolver captures the worker-lifetime container. */ public static function resolveUsing(Container $app): void { - Paginator::viewFactoryResolver(fn () => $app['view']); + Paginator::viewFactoryResolver(fn (): Factory => $app->make('view')); - Paginator::currentPathResolver(function () use ($app): string { - if (! RequestContext::has()) { - return '/'; - } + Paginator::currentPathResolver(static function (): string { + $request = RequestContext::getOrNull(); - return $app['request']->url(); + return $request?->url() ?? '/'; }); - Paginator::currentPageResolver(function (string $pageName = 'page') use ($app): int { - if (! RequestContext::has()) { + Paginator::currentPageResolver(static function (string $pageName = 'page'): int { + $request = RequestContext::getOrNull(); + + if ($request === null) { return 1; } - $page = $app['request']->input($pageName); - - if (filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1) { - return (int) $page; - } + $page = $request->input($pageName); - return 1; + return filter_var($page, FILTER_VALIDATE_INT) !== false && (int) $page >= 1 + ? (int) $page + : 1; }); - Paginator::queryStringResolver(function () use ($app): array { - if (! RequestContext::has()) { - return []; - } + Paginator::queryStringResolver(static function (): array { + $request = RequestContext::getOrNull(); - return $app['request']->query(); + return $request?->query() ?? []; }); - CursorPaginator::currentCursorResolver(function (string $cursorName = 'cursor') use ($app): ?Cursor { - if (! RequestContext::has()) { - return null; - } + CursorPaginator::currentCursorResolver(static function (string $cursorName = 'cursor'): ?Cursor { + $request = RequestContext::getOrNull(); - return Cursor::fromEncoded($app['request']->input($cursorName)); + return $request === null + ? null + : Cursor::fromEncoded($request->input($cursorName)); }); } } diff --git a/src/pagination/src/Paginator.php b/src/pagination/src/Paginator.php index 0abaef8df..69e340e0c 100644 --- a/src/pagination/src/Paginator.php +++ b/src/pagination/src/Paginator.php @@ -10,8 +10,10 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Htmlable; use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Contracts\View\View; use Hypervel\Support\Collection; use IteratorAggregate; +use JsonException; use JsonSerializable; /** @@ -59,7 +61,7 @@ public function __construct(mixed $items, int $perPage, ?int $currentPage = null */ protected function setCurrentPage(?int $currentPage): int { - $currentPage = $currentPage ?: static::resolveCurrentPage(); + $currentPage = $currentPage ?? static::resolveCurrentPage(); return $this->isValidPageNumber($currentPage) ? (int) $currentPage : 1; } @@ -107,6 +109,8 @@ public function links(?string $view = null, array $data = []): Htmlable */ public function render(?string $view = null, array $data = []): Htmlable { + // Laravel's View contract omits Htmlable, but factory-created views fulfill both contracts. + /** @var Htmlable&View */ return static::viewFactory()->make($view ?: static::$defaultSimpleView, array_merge($data, [ 'paginator' => $this, ])); @@ -165,14 +169,18 @@ public function jsonSerialize(): array /** * Convert the object to its JSON representation. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->jsonSerialize(), $options); + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); } /** * Convert the object to pretty print formatted JSON. + * + * @throws JsonException */ public function toPrettyJson(int $options = 0): string { diff --git a/src/pagination/src/UrlWindow.php b/src/pagination/src/UrlWindow.php index 8c0b73525..e769f558f 100644 --- a/src/pagination/src/UrlWindow.php +++ b/src/pagination/src/UrlWindow.php @@ -38,10 +38,16 @@ public static function make(PaginatorContract $paginator): array */ public function get(): array { - /** @phpstan-ignore property.notFound (onEachSide is a public property on the concrete class) */ - $onEachSide = $this->paginator->onEachSide; + /** + * Keep the readable window-size requirement local so the public contract remains mockable. + * + * @var object{onEachSide: int}&PaginatorContract $paginator + */ + $paginator = $this->paginator; - if ($this->paginator->lastPage() < ($onEachSide * 2) + 8) { + $onEachSide = $paginator->onEachSide; + + if ($paginator->lastPage() < ($onEachSide * 2) + 8) { return $this->getSmallSlider(); } diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index 33126257f..511afbef2 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -786,6 +786,8 @@ public function secure(): bool /** * Get or set the domain for the route. * + * @return ($domain is null ? null|string : $this) + * * @throws InvalidArgumentException */ public function domain(BackedEnum|string|null $domain = null): static|string|null diff --git a/src/sanctum/src/NewAccessToken.php b/src/sanctum/src/NewAccessToken.php index 60772b08c..8c6ff9f89 100644 --- a/src/sanctum/src/NewAccessToken.php +++ b/src/sanctum/src/NewAccessToken.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; +use JsonException; use Stringable; class NewAccessToken implements Stringable, Arrayable, Jsonable @@ -34,10 +35,12 @@ public function toArray(): array /** * Convert the object to its JSON representation. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->toArray(), $options); + return json_encode($this->toArray(), $options | JSON_THROW_ON_ERROR); } /** diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php index 5d1df2acc..da2de01b6 100644 --- a/src/scout/src/Builder.php +++ b/src/scout/src/Builder.php @@ -383,7 +383,7 @@ public function simplePaginate( ): PaginatorContract { $engine = $this->preparedEngine(); - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = max(1, $page ?? Paginator::resolveCurrentPage($pageName)); $perPage = $perPage ?: $this->model->getPerPage(); if ($engine instanceof PaginatesEloquentModels) { @@ -426,7 +426,7 @@ public function paginate( ): LengthAwarePaginatorContract { $engine = $this->preparedEngine(); - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = max(1, $page ?? Paginator::resolveCurrentPage($pageName)); $perPage = $perPage ?: $this->model->getPerPage(); if ($engine instanceof PaginatesEloquentModels) { @@ -468,7 +468,7 @@ public function paginateRaw( ): LengthAwarePaginatorContract { $engine = $this->preparedEngine(); - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = max(1, $page ?? Paginator::resolveCurrentPage($pageName)); $perPage = $perPage ?: $this->model->getPerPage(); if ($engine instanceof PaginatesEloquentModels) { @@ -505,7 +505,7 @@ public function simplePaginateRaw( ): PaginatorContract { $engine = $this->preparedEngine(); - $page = $page ?: Paginator::resolveCurrentPage($pageName); + $page = max(1, $page ?? Paginator::resolveCurrentPage($pageName)); $perPage = $perPage ?: $this->model->getPerPage(); if ($engine instanceof PaginatesEloquentModels) { diff --git a/src/support/src/Fluent.php b/src/support/src/Fluent.php index 74f706546..d0a6778e2 100644 --- a/src/support/src/Fluent.php +++ b/src/support/src/Fluent.php @@ -13,6 +13,7 @@ use Hypervel\Support\Traits\InteractsWithData; use Hypervel\Support\Traits\Macroable; use IteratorAggregate; +use JsonException; use JsonSerializable; use Traversable; @@ -175,14 +176,18 @@ public function jsonSerialize(): array /** * Convert the fluent instance to JSON. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->jsonSerialize(), $options); + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); } /** * Convert the fluent instance to pretty print formatted JSON. + * + * @throws JsonException */ public function toPrettyJson(int $options = 0): string { diff --git a/src/support/src/Lottery.php b/src/support/src/Lottery.php index a065378b9..d3199573c 100644 --- a/src/support/src/Lottery.php +++ b/src/support/src/Lottery.php @@ -99,6 +99,8 @@ public function __invoke(mixed ...$args): mixed /** * Run the lottery. + * + * @return ($times is null ? mixed : list) */ public function choose(?int $times = null): mixed { diff --git a/src/support/src/MessageBag.php b/src/support/src/MessageBag.php index ddbfd85f1..966850e3a 100755 --- a/src/support/src/MessageBag.php +++ b/src/support/src/MessageBag.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Support\Jsonable; use Hypervel\Contracts\Support\MessageBag as MessageBagContract; use Hypervel\Contracts\Support\MessageProvider; +use JsonException; use JsonSerializable; use Stringable; @@ -370,14 +371,18 @@ public function jsonSerialize(): array /** * Convert the object to its JSON representation. + * + * @throws JsonException */ public function toJson(int $options = 0): string { - return json_encode($this->jsonSerialize(), $options); + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); } /** * Convert the object to pretty print formatted JSON. + * + * @throws JsonException */ public function toPrettyJson(int $options = 0): string { diff --git a/tests/ApiClient/ApiResourceTest.php b/tests/ApiClient/ApiResourceTest.php index 12973f043..81ecdb616 100644 --- a/tests/ApiClient/ApiResourceTest.php +++ b/tests/ApiClient/ApiResourceTest.php @@ -9,6 +9,7 @@ use Hypervel\ApiClient\ApiResource; use Hypervel\ApiClient\ApiResponse; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; use Mockery\MockInterface; @@ -82,6 +83,25 @@ public function testJsonSerialize(): void $this->assertEquals($jsonData, $this->resource->jsonSerialize()); } + public function testToJsonThrowsForInvalidUtf8(): void + { + $this->response->shouldReceive('json')->once()->andReturn(['value' => "\xB1\x31"]); + + $this->expectException(JsonException::class); + + $this->resource->toJson(); + } + + public function testToJsonHonorsInvalidUtf8Substitution(): void + { + $this->response->shouldReceive('json')->once()->andReturn(['value' => "\xB1\x31"]); + + $this->assertSame( + '{"value":"\ufffd1"}', + $this->resource->toJson(JSON_INVALID_UTF8_SUBSTITUTE), + ); + } + public function testArrayAccessOffsetExists(): void { $this->response->shouldReceive('offsetExists') diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 2354a84ff..e7117275d 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -304,6 +304,24 @@ public function testPaginatedModelCollectionRetrieval() $this->assertSame('foo@gmail.com', $models[0]->email); } + public function testExplicitZeroPageDoesNotConsultResolver(): void + { + User::insert([ + ['id' => 1, 'email' => 'taylorotwell@gmail.com'], + ['id' => 2, 'email' => 'abigailotwell@gmail.com'], + ]); + + Paginator::currentPageResolver(fn () => 9); + + $paginator = User::oldest('id')->paginate(1, page: 0); + $simplePaginator = User::oldest('id')->simplePaginate(1, page: 0); + + $this->assertSame(1, $paginator->currentPage()); + $this->assertSame(1, $paginator->first()->getKey()); + $this->assertSame(1, $simplePaginator->currentPage()); + $this->assertSame(1, $simplePaginator->first()->getKey()); + } + public function testPaginatedModelCollectionRetrievalUsingCallablePerPage() { User::insert([ diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 5fc7c34bf..f13e8a53f 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -5805,6 +5805,32 @@ public function testPaginateWithDefaultArguments() ]), $result); } + public function testPaginateWithExplicitZeroPageDoesNotConsultResolver(): void + { + $builder = $this->getMockQueryBuilder(); + $results = collect([['test' => 'foo']]); + + Paginator::currentPageResolver(fn () => 9); + + $builder->shouldReceive('getCountForPagination')->once()->andReturn(1); + $builder->shouldReceive('forPage')->once()->with(0, 15)->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn($results); + + $this->assertSame(1, $builder->paginate(page: 0)->currentPage()); + } + + public function testSimplePaginateWithExplicitZeroPageDoesNotConsultResolver(): void + { + $builder = $this->getMockQueryBuilder(); + + Paginator::currentPageResolver(fn () => 9); + + $builder->shouldReceive('get')->once()->andReturn(collect([['test' => 'foo']])); + + $this->assertSame(1, $builder->simplePaginate(page: 0)->currentPage()); + $this->assertSame(0, $builder->offset); + } + public function testPaginateWhenNoResults() { $perPage = 15; @@ -5886,6 +5912,14 @@ public function testPaginateWithTotalOverride() $this->assertEquals(10, $result->total()); } + public function testCursorPaginateTruthfullyDeclaresItsIntegerPerPageBoundary(): void + { + // The shared cursor paginator requires an integer, so nullable metadata would promise an input that fails internally. + $parameter = (new ReflectionMethod(Builder::class, 'cursorPaginate'))->getParameters()[0]; + + $this->assertSame('int', (string) $parameter->getType()); + } + public function testCursorPaginate() { $perPage = 16; diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index f44a54215..5dd5c2bdd 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Integration\Database; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Schema\Blueprint; use Hypervel\Pagination\Cursor; use Hypervel\Support\Facades\DB; @@ -17,6 +19,7 @@ protected function afterRefreshingDatabase(): void Schema::create('test_posts', function (Blueprint $table) { $table->increments('id'); $table->string('title')->nullable(); + $table->float('score')->nullable(); $table->unsignedInteger('user_id')->nullable(); $table->timestamps(); }); @@ -24,8 +27,19 @@ protected function afterRefreshingDatabase(): void Schema::create('test_users', function ($table) { $table->increments('id'); $table->string('name')->nullable(); + $table->unsignedInteger('owner_id')->nullable(); $table->timestamps(); }); + + Schema::create('test_owners', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + Schema::create('test_post_user', function ($table) { + $table->unsignedInteger('post_id'); + $table->unsignedInteger('user_id'); + }); } public function testCursorPaginationOnTopOfColumns() @@ -294,6 +308,53 @@ public function testPaginationWithDistinctColumnsAndSelectAndJoin() $this->assertEquals(5, $query->count()); $this->assertCount(5, $query->cursorPaginate()->items()); } + + public function testCursorPaginationPreservesFloatOrderValues(): void + { + TestPost::fillAndInsert([ + ['score' => 1.25], + ['score' => 2.5], + ['score' => 3.75], + ]); + + $result = TestPost::query() + ->orderBy('score') + ->orderBy('id') + ->cursorPaginate(1, ['*'], 'cursor', new Cursor(['score' => 1.25, 'id' => 1])); + + $this->assertSame(2, $result->items()[0]->id); + $this->assertSame(2.5, $result->items()[0]->score); + } + + public function testRelationsAcceptCursorObjects(): void + { + TestOwner::fillAndInsert([[]]); + TestUser::fillAndInsert([ + ['owner_id' => 1], + ['owner_id' => 1], + ]); + TestPost::fillAndInsert([ + ['user_id' => 1], + ['user_id' => 2], + ]); + DB::table('test_post_user')->insert([ + ['post_id' => 1, 'user_id' => 1], + ['post_id' => 2, 'user_id' => 1], + ]); + + $belongsToMany = TestUser::findOrFail(1) + ->favoritePosts() + ->orderBy('id') + ->cursorPaginate(1, ['*'], 'cursor', new Cursor(['id' => 1])); + + $hasManyThrough = TestOwner::findOrFail(1) + ->posts() + ->orderBy('test_posts.id') + ->cursorPaginate(1, ['test_posts.*'], 'cursor', new Cursor(['test_posts.id' => 1])); + + $this->assertSame(2, $belongsToMany->items()[0]->id); + $this->assertSame(2, $hasManyThrough->items()[0]->id); + } } class TestPost extends Model @@ -309,4 +370,21 @@ public function posts() { return $this->hasMany(TestPost::class, 'user_id'); } + + /** @return BelongsToMany */ + public function favoritePosts(): BelongsToMany + { + return $this->belongsToMany(TestPost::class, 'test_post_user', 'user_id', 'post_id'); + } +} + +class TestOwner extends Model +{ + protected array $guarded = []; + + /** @return HasManyThrough */ + public function posts(): HasManyThrough + { + return $this->hasManyThrough(TestPost::class, TestUser::class, 'owner_id', 'user_id'); + } } diff --git a/tests/Pagination/CursorPaginatorLoadMorphCountTest.php b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php index 29e106cc2..0e7d2245a 100644 --- a/tests/Pagination/CursorPaginatorLoadMorphCountTest.php +++ b/tests/Pagination/CursorPaginatorLoadMorphCountTest.php @@ -12,7 +12,7 @@ class CursorPaginatorLoadMorphCountTest extends TestCase { - public function testCollectionLoadMorphCountCanChainOnThePaginator() + public function testCollectionLoadMorphCountCanChainOnThePaginator(): void { $relations = [ 'App\User' => 'photos', diff --git a/tests/Pagination/CursorPaginatorLoadMorphTest.php b/tests/Pagination/CursorPaginatorLoadMorphTest.php index 6db6888c4..58c7da7b4 100644 --- a/tests/Pagination/CursorPaginatorLoadMorphTest.php +++ b/tests/Pagination/CursorPaginatorLoadMorphTest.php @@ -12,7 +12,7 @@ class CursorPaginatorLoadMorphTest extends TestCase { - public function testCollectionLoadMorphCanChainOnThePaginator() + public function testCollectionLoadMorphCanChainOnThePaginator(): void { $relations = [ 'App\User' => 'photos', diff --git a/tests/Pagination/CursorPaginatorTest.php b/tests/Pagination/CursorPaginatorTest.php index 223ceadd6..8c1e29d45 100644 --- a/tests/Pagination/CursorPaginatorTest.php +++ b/tests/Pagination/CursorPaginatorTest.php @@ -12,11 +12,13 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; +use InvalidArgumentException; +use JsonException; use PHPUnit\Framework\Attributes\DataProvider; class CursorPaginatorTest extends TestCase { - public function testReturnsRelevantContextInformation() + public function testReturnsRelevantContextInformation(): void { $p = new CursorPaginator($array = [['id' => 1], ['id' => 2], ['id' => 3]], 2, null, [ 'parameters' => ['id'], @@ -39,7 +41,7 @@ public function testReturnsRelevantContextInformation() $this->assertEquals($pageInfo, $p->toArray()); } - public function testPaginatorRemovesTrailingSlashes() + public function testPaginatorRemovesTrailingSlashes(): void { $p = new CursorPaginator( $array = [['id' => 4], ['id' => 5], ['id' => 6]], @@ -51,7 +53,7 @@ public function testPaginatorRemovesTrailingSlashes() $this->assertSame('http://website.com/test?cursor=' . $this->getCursor(['id' => 5]), $p->nextPageUrl()); } - public function testPaginatorGeneratesUrlsWithoutTrailingSlash() + public function testPaginatorGeneratesUrlsWithoutTrailingSlash(): void { $p = new CursorPaginator( $array = [['id' => 4], ['id' => 5], ['id' => 6]], @@ -63,7 +65,7 @@ public function testPaginatorGeneratesUrlsWithoutTrailingSlash() $this->assertSame('http://website.com/test?cursor=' . $this->getCursor(['id' => 5]), $p->nextPageUrl()); } - public function testItRetrievesThePaginatorOptions() + public function testItRetrievesThePaginatorOptions(): void { $p = new CursorPaginator( $array = [['id' => 4], ['id' => 5], ['id' => 6]], @@ -75,7 +77,7 @@ public function testItRetrievesThePaginatorOptions() $this->assertSame($p->getOptions(), $options); } - public function testPaginatorReturnsPath() + public function testPaginatorReturnsPath(): void { $p = new CursorPaginator( $array = [['id' => 4], ['id' => 5], ['id' => 6]], @@ -87,7 +89,7 @@ public function testPaginatorReturnsPath() $this->assertSame($p->path(), 'http://website.com/test'); } - public function testCanTransformPaginatorItems() + public function testCanTransformPaginatorItems(): void { $p = new CursorPaginator( $array = [['id' => 4], ['id' => 5], ['id' => 6]], @@ -106,7 +108,7 @@ public function testCanTransformPaginatorItems() $this->assertSame([['id' => 6], ['id' => 7]], $p->items()); } - public function testCursorPaginatorOnFirstAndLastPage() + public function testCursorPaginatorOnFirstAndLastPage(): void { $paginator = new CursorPaginator([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4]], 2, null, [ 'parameters' => ['id'], @@ -124,7 +126,27 @@ public function testCursorPaginatorOnFirstAndLastPage() $this->assertTrue($paginator->onLastPage()); } - public function testReturnEmptyCursorWhenItemsAreEmpty() + public function testItemsAreConsistentlyReindexedForNextAndPreviousPages(): void + { + $next = new CursorPaginator([ + 10 => ['id' => 1], + 20 => ['id' => 2], + 30 => ['id' => 3], + ], 2, null, ['parameters' => ['id']]); + + $this->assertSame([0, 1], array_keys($next->items())); + + $previous = new CursorPaginator([ + 10 => ['id' => 4], + 20 => ['id' => 3], + 30 => ['id' => 2], + ], 2, new Cursor(['id' => 5], false), ['parameters' => ['id']]); + + $this->assertSame([0, 1], array_keys($previous->items())); + $this->assertSame([['id' => 3], ['id' => 4]], $previous->items()); + } + + public function testReturnEmptyCursorWhenItemsAreEmpty(): void { $cursor = new Cursor(['id' => 25], true); @@ -147,7 +169,7 @@ public function testReturnEmptyCursorWhenItemsAreEmpty() ], $p->toArray()); } - public function testCursorPaginatorToJson() + public function testCursorPaginatorToJson(): void { $paginator = new CursorPaginator([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4]], 2, null); $results = $paginator->toJson(); @@ -157,7 +179,7 @@ public function testCursorPaginatorToJson() $this->assertSame($expected, $results); } - public function testCursorPaginatorToPrettyJson() + public function testCursorPaginatorToPrettyJson(): void { $paginator = new CursorPaginator([['id' => '1'], ['id' => '2'], ['id' => '3'], ['id' => '4']], 2, null); $results = $paginator->toPrettyJson(); @@ -174,7 +196,7 @@ public function testCursorPaginatorToPrettyJson() $this->assertStringContainsString('"id": 1', $results); } - public function testNextCursorReturnsCursorObject() + public function testNextCursorReturnsCursorObject(): void { $p = new CursorPaginator([['id' => 1], ['id' => 2], ['id' => 3]], 2, null, [ 'parameters' => ['id'], @@ -186,7 +208,7 @@ public function testNextCursorReturnsCursorObject() $this->assertSame(2, $nextCursor->parameter('id')); } - public function testPreviousCursorReturnsCursorObject() + public function testPreviousCursorReturnsCursorObject(): void { $cursor = new Cursor(['id' => 3], true); $p = new CursorPaginator([['id' => 3], ['id' => 4], ['id' => 5]], 2, $cursor, [ @@ -199,7 +221,7 @@ public function testPreviousCursorReturnsCursorObject() $this->assertSame(3, $previousCursor->parameter('id')); } - public function testPreviousCursorReturnsNullWhenNoCursor() + public function testPreviousCursorReturnsNullWhenNoCursor(): void { $p = new CursorPaginator([['id' => 1], ['id' => 2]], 2, null, [ 'parameters' => ['id'], @@ -208,7 +230,7 @@ public function testPreviousCursorReturnsNullWhenNoCursor() $this->assertNull($p->previousCursor()); } - public function testNextCursorReturnsNullOnLastPage() + public function testNextCursorReturnsNullOnLastPage(): void { $cursor = new Cursor(['id' => 3]); $p = new CursorPaginator([['id' => 3], ['id' => 4]], 2, $cursor, [ @@ -218,7 +240,7 @@ public function testNextCursorReturnsNullOnLastPage() $this->assertNull($p->nextCursor()); } - public function testGetCursorForItem() + public function testGetCursorForItem(): void { $p = new CursorPaginator([['id' => 1]], 1, null, [ 'parameters' => ['id'], @@ -233,7 +255,20 @@ public function testGetCursorForItem() $this->assertTrue($cursor->pointsToPreviousItems()); } - public function testGetParametersForItem() + public function testArrayCursorParametersAreRejectedWhenGeneratingCursor(): void + { + $paginator = new CursorPaginator([ + ['id' => [5, 9]], + ['id' => [7, 11]], + ], 1, null, ['parameters' => ['id']]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor parameter [id] must not be an array.'); + + $paginator->nextCursor(); + } + + public function testGetParametersForItem(): void { $p = new CursorPaginator([['id' => 1, 'name' => 'a']], 1, null, [ 'parameters' => ['id', 'name'], @@ -290,21 +325,23 @@ public static function validCursorParameterProvider(): array ]; } - public function testIntegerPivotCursorParametersArePreserved(): void + public function testMixedPivotCursorParametersArePreserved(): void { - $model = new CursorPaginatorModel; - $pivot = new Pivot; - $pivot->setTable('role_user'); - $pivot->setRawAttributes(['position' => 7], true); - $model->setRelation('membership', $pivot); - $paginator = new CursorPaginator([$model], 1, null, [ - 'parameters' => ['role_user.position'], - ]); + foreach ([7, true, 4.25] as $value) { + $model = new CursorPaginatorModel; + $pivot = new Pivot; + $pivot->setTable('role_user'); + $pivot->setRawAttributes(['position' => $value], true); + $model->setRelation('membership', $pivot); + $paginator = new CursorPaginator([$model], 1, null, [ + 'parameters' => ['role_user.position'], + ]); - $this->assertSame( - ['role_user.position' => 7], - $paginator->getParametersForItem($model), - ); + $this->assertSame( + ['role_user.position' => $value], + $paginator->getParametersForItem($model), + ); + } } public function testNullPivotCursorParametersThrow(): void @@ -324,7 +361,7 @@ public function testNullPivotCursorParametersThrow(): void $paginator->getParametersForItem($model); } - public function testFragmentAppearsInUrl() + public function testFragmentAppearsInUrl(): void { $p = new CursorPaginator([['id' => 1], ['id' => 2], ['id' => 3]], 2, null, [ 'parameters' => ['id'], @@ -337,7 +374,7 @@ public function testFragmentAppearsInUrl() $this->assertStringContainsString('#section', $nextUrl); } - public function testAppendsQueryParams() + public function testAppendsQueryParams(): void { $p = new CursorPaginator([['id' => 1], ['id' => 2], ['id' => 3]], 2, null, [ 'parameters' => ['id'], @@ -348,7 +385,23 @@ public function testAppendsQueryParams() $this->assertStringContainsString('sort=name', $nextUrl); } - public function testCursorReturnsCurrentCursor() + public function testAppendsPreservesIntegerKeysAndSupportedValues(): void + { + $paginator = new CursorPaginator([], 1); + $paginator->appends([ + 2 => 'two', + 5 => 4.25, + 'enabled' => true, + 'filters' => ['status' => 'active'], + ]); + + $this->assertSame( + '/?2=two&5=4.25&enabled=1&filters%5Bstatus%5D=active', + $paginator->url(null), + ); + } + + public function testCursorReturnsCurrentCursor(): void { $cursor = new Cursor(['id' => 10], true); $p = new CursorPaginator([['id' => 10]], 1, $cursor, [ @@ -358,14 +411,14 @@ public function testCursorReturnsCurrentCursor() $this->assertSame($cursor, $p->cursor()); } - public function testCursorReturnsNullWhenNoCursor() + public function testCursorReturnsNullWhenNoCursor(): void { $p = new CursorPaginator([['id' => 1]], 1, null); $this->assertNull($p->cursor()); } - public function testGetCursorNameAndSetCursorName() + public function testGetCursorNameAndSetCursorName(): void { $p = new CursorPaginator([['id' => 1]], 1, null); @@ -376,7 +429,7 @@ public function testGetCursorNameAndSetCursorName() $this->assertSame('page_cursor', $p->getCursorName()); } - public function testIsEmptyAndIsNotEmpty() + public function testIsEmptyAndIsNotEmpty(): void { $p = new CursorPaginator([], 2, null); $this->assertTrue($p->isEmpty()); @@ -387,14 +440,14 @@ public function testIsEmptyAndIsNotEmpty() $this->assertTrue($p->isNotEmpty()); } - public function testCount() + public function testCount(): void { $p = new CursorPaginator([['id' => 1], ['id' => 2], ['id' => 3]], 3, null); $this->assertSame(3, $p->count()); } - public function testArrayAccess() + public function testArrayAccess(): void { $p = new CursorPaginator([['id' => 1], ['id' => 2], ['id' => 3]], 3, null); @@ -414,7 +467,32 @@ public function testArrayAccess() $this->assertFalse(isset($p[0])); } - protected function getCursor($params, $isNext = true) + public function testCursorPaginatorJsonThrowsForInvalidUtf8(): void + { + $paginator = new CursorPaginator([['id' => "\xB1\x31"]], 1); + + $this->expectException(JsonException::class); + + $paginator->toJson(); + } + + public function testCursorPaginatorPrettyJsonPropagatesInvalidUtf8Failure(): void + { + $paginator = new CursorPaginator([['id' => "\xB1\x31"]], 1); + + $this->expectException(JsonException::class); + + $paginator->toPrettyJson(); + } + + public function testCursorPaginatorJsonHonorsInvalidUtf8Substitution(): void + { + $paginator = new CursorPaginator([['id' => "\xB1\x31"]], 1); + + $this->assertStringContainsString('\ufffd1', $paginator->toJson(JSON_INVALID_UTF8_SUBSTITUTE)); + } + + protected function getCursor(array $params, bool $isNext = true): string { return (new Cursor($params, $isNext))->encode(); } diff --git a/tests/Pagination/CursorResourceTest.php b/tests/Pagination/CursorResourceTest.php index e254f8105..6af0bcb81 100644 --- a/tests/Pagination/CursorResourceTest.php +++ b/tests/Pagination/CursorResourceTest.php @@ -12,7 +12,7 @@ class CursorResourceTest extends TestCase { - public function testItCanTransformToExplicitResource() + public function testItCanTransformToExplicitResource(): void { $paginator = new CursorResourceTestPaginator([ new CursorResourceTestModel, @@ -23,7 +23,7 @@ public function testItCanTransformToExplicitResource() $this->assertInstanceOf(JsonResource::class, $resource); } - public function testItThrowsExceptionWhenResourceCannotBeFound() + public function testItThrowsExceptionWhenResourceCannotBeFound(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage('Failed to find resource class for model [Hypervel\Tests\Pagination\Fixtures\Models\CursorResourceTestModel].'); @@ -35,7 +35,7 @@ public function testItThrowsExceptionWhenResourceCannotBeFound() $paginator->toResourceCollection(); } - public function testItCanGuessResourceWhenNotProvided() + public function testItCanGuessResourceWhenNotProvided(): void { $paginator = new CursorResourceTestPaginator([ new CursorResourceTestModel, diff --git a/tests/Pagination/CursorTest.php b/tests/Pagination/CursorTest.php index 4f42327a1..358f42112 100644 --- a/tests/Pagination/CursorTest.php +++ b/tests/Pagination/CursorTest.php @@ -7,6 +7,8 @@ use Hypervel\Pagination\Cursor; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; +use InvalidArgumentException; +use JsonException; use UnexpectedValueException; class CursorTest extends TestCase @@ -31,6 +33,28 @@ public function testCanGetParams(): void $this->assertEquals([$now, 422], $cursor->parameters(['created_at', 'id'])); } + public function testCanGetMixedParams(): void + { + $cursor = new Cursor([ + 'active' => true, + 'score' => 4.25, + ]); + + $this->assertSame([true, 4.25], $cursor->parameters(['active', 'score'])); + + $decodedCursor = Cursor::fromEncoded($cursor->encode()); + + $this->assertNotNull($decodedCursor); + $this->assertSame([true, 4.25], $decodedCursor->parameters(['active', 'score'])); + } + + public function testCanGetBackedEnumParam(): void + { + $cursor = new Cursor(['status' => CursorTestStatus::Active]); + + $this->assertSame(CursorTestStatus::Active, $cursor->parameter('status')); + } + public function testCanGetParam(): void { $cursor = new Cursor([ @@ -75,9 +99,16 @@ public function testToArray(): void ], $cursor->toArray()); } - public function testFromEncodedReturnsNullForNull(): void + public function testFromEncodedReturnsNullForNonStringInput(): void { $this->assertNull(Cursor::fromEncoded(null)); + $this->assertNull(Cursor::fromEncoded(123)); + $this->assertNull(Cursor::fromEncoded(['cursor'])); + } + + public function testFromEncodedReturnsNullForInvalidJson(): void + { + $this->assertNull(Cursor::fromEncoded(base64_encode('not-json'))); } public function testFromEncodedReturnsNullForInvalidString(): void @@ -85,6 +116,58 @@ public function testFromEncodedReturnsNullForInvalidString(): void $this->assertNull(Cursor::fromEncoded('not-valid-json!@#')); } + public function testFromEncodedReturnsNullWhenDecodedPayloadIsNotAnArray(): void + { + $this->assertNull(Cursor::fromEncoded(base64_encode(json_encode('scalar', JSON_THROW_ON_ERROR)))); + $this->assertNull(Cursor::fromEncoded(base64_encode(json_encode(null, JSON_THROW_ON_ERROR)))); + } + + public function testFromEncodedReturnsNullWhenPointsToNextItemsKeyIsMissing(): void + { + $payload = base64_encode(json_encode(['id' => 422], JSON_THROW_ON_ERROR)); + + $this->assertNull(Cursor::fromEncoded($payload)); + } + + public function testFromEncodedReturnsNullWhenPointsToNextItemsIsNotBoolean(): void + { + foreach ([null, 0, 1, '0', '1', []] as $direction) { + $payload = base64_encode(json_encode([ + 'id' => 422, + '_pointsToNextItems' => $direction, + ], JSON_THROW_ON_ERROR)); + + $this->assertNull(Cursor::fromEncoded($payload)); + } + } + + public function testFromEncodedReturnsNullForStructuredParameters(): void + { + foreach ([[5, 9], [['value' => 3]], []] as $parameter) { + $payload = base64_encode(json_encode([ + 'id' => $parameter, + '_pointsToNextItems' => true, + ], JSON_THROW_ON_ERROR)); + + $this->assertNull(Cursor::fromEncoded($payload)); + } + } + + public function testConstructorRejectsArrayParameters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cursor parameter [id] must not be an array.'); + + new Cursor(['id' => [5, 9]]); + } + + public function testEncodeThrowsForInvalidUtf8(): void + { + $this->expectException(JsonException::class); + + (new Cursor(['value' => "\xB1\x31"]))->encode(); + } + public function testParameterThrowsForMissingKey(): void { $cursor = new Cursor(['id' => 1], true); @@ -95,3 +178,8 @@ public function testParameterThrowsForMissingKey(): void $cursor->parameter('missing'); } } + +enum CursorTestStatus: string +{ + case Active = 'active'; +} diff --git a/tests/Pagination/LengthAwarePaginatorTest.php b/tests/Pagination/LengthAwarePaginatorTest.php index 62064a15f..979760646 100644 --- a/tests/Pagination/LengthAwarePaginatorTest.php +++ b/tests/Pagination/LengthAwarePaginatorTest.php @@ -6,7 +6,9 @@ use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Collection; use Hypervel\Testbench\TestCase; +use JsonException; class LengthAwarePaginatorTest extends TestCase { @@ -29,7 +31,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testLengthAwarePaginatorGetAndSetPageName() + public function testLengthAwarePaginatorGetAndSetPageName(): void { $this->assertSame('page', $this->p->getPageName()); @@ -37,7 +39,7 @@ public function testLengthAwarePaginatorGetAndSetPageName() $this->assertSame('p', $this->p->getPageName()); } - public function testLengthAwarePaginatorCanGiveMeRelevantPageInformation() + public function testLengthAwarePaginatorCanGiveMeRelevantPageInformation(): void { $this->assertEquals(2, $this->p->lastPage()); $this->assertEquals(2, $this->p->currentPage()); @@ -46,7 +48,7 @@ public function testLengthAwarePaginatorCanGiveMeRelevantPageInformation() $this->assertEquals(['item1', 'item2', 'item3', 'item4'], $this->p->items()); } - public function testLengthAwarePaginatorSetCorrectInformationWithNoItems() + public function testLengthAwarePaginatorSetCorrectInformationWithNoItems(): void { $paginator = new LengthAwarePaginator([], 0, 2, 1); @@ -57,7 +59,7 @@ public function testLengthAwarePaginatorSetCorrectInformationWithNoItems() $this->assertEmpty($paginator->items()); } - public function testLengthAwarePaginatorOnFirstAndLastPage() + public function testLengthAwarePaginatorOnFirstAndLastPage(): void { $paginator = new LengthAwarePaginator(['1', '2', '3', '4'], 4, 2, 2); @@ -70,7 +72,7 @@ public function testLengthAwarePaginatorOnFirstAndLastPage() $this->assertTrue($paginator->onFirstPage()); } - public function testLengthAwarePaginatorCanGenerateUrls() + public function testLengthAwarePaginatorCanGenerateUrls(): void { $this->p->setPath('http://website.com'); $this->p->setPageName('foo'); @@ -96,7 +98,7 @@ public function testLengthAwarePaginatorCanGenerateUrls() ); } - public function testLengthAwarePaginatorCanGenerateUrlsWithQuery() + public function testLengthAwarePaginatorCanGenerateUrlsWithQuery(): void { $this->p->setPath('http://website.com?sort_by=date'); $this->p->setPageName('foo'); @@ -107,7 +109,7 @@ public function testLengthAwarePaginatorCanGenerateUrlsWithQuery() ); } - public function testLengthAwarePaginatorCanGenerateUrlsWithoutTrailingSlashes() + public function testLengthAwarePaginatorCanGenerateUrlsWithoutTrailingSlashes(): void { $this->p->setPath('http://website.com/test'); $this->p->setPageName('foo'); @@ -128,7 +130,7 @@ public function testLengthAwarePaginatorCanGenerateUrlsWithoutTrailingSlashes() ); } - public function testLengthAwarePaginatorCorrectlyGenerateUrlsWithQueryAndSpaces() + public function testLengthAwarePaginatorCorrectlyGenerateUrlsWithQueryAndSpaces(): void { $this->p->setPath('http://website.com?key=value%20with%20spaces'); $this->p->setPageName('foo'); @@ -139,12 +141,12 @@ public function testLengthAwarePaginatorCorrectlyGenerateUrlsWithQueryAndSpaces( ); } - public function testItRetrievesThePaginatorOptions() + public function testItRetrievesThePaginatorOptions(): void { $this->assertSame($this->options, $this->p->getOptions()); } - public function testNextPageUrl() + public function testNextPageUrl(): void { $paginator = new LengthAwarePaginator([1, 2], 10, 2); @@ -155,7 +157,7 @@ public function testNextPageUrl() $this->assertSame(null, $paginator->nextPageUrl()); } - public function testFirstItem() + public function testFirstItem(): void { $paginator = new LengthAwarePaginator([1, 2], 10, 2, 2); @@ -163,7 +165,7 @@ public function testFirstItem() $this->assertSame(4, $paginator->lastItem()); } - public function testAppends() + public function testAppends(): void { $paginator = new LengthAwarePaginator([1, 2], 10, 2, 2); $paginator = $paginator->appends('keyword', 'Hypervel'); @@ -182,14 +184,14 @@ public function testAppends() $this->assertSame('/?settings%5Bid%5D=1&settings%5Bname%5D=Hypervel&page=1', $paginator->url(1)); } - public function testToArrayIncludesCurrentPageUrl() + public function testToArrayIncludesCurrentPageUrl(): void { $paginator = new LengthAwarePaginator([1, 2], 10, 2, 2); $this->assertSame('/?page=2', $paginator->toArray()['current_page_url']); } - public function testCurrentPageUrlIncludesAppendedQueryParams() + public function testCurrentPageUrlIncludesAppendedQueryParams(): void { $paginator = (new LengthAwarePaginator([1, 2], 10, 2, 2)) ->appends('sort', 'votes') @@ -198,7 +200,7 @@ public function testCurrentPageUrlIncludesAppendedQueryParams() $this->assertSame('/?sort=votes&page=2#users', $paginator->toArray()['current_page_url']); } - public function testToJson() + public function testToJson(): void { $paginator = new LengthAwarePaginator([1, 2], 10, 2, 2); @@ -209,20 +211,20 @@ public function testToJson() $this->assertSame(json_encode($paginator->toArray()), $paginator->toJson()); } - public function testTotal() + public function testTotal(): void { $paginator = new LengthAwarePaginator([1, 2], 50, 2, 1); $this->assertSame(50, $paginator->total()); } - public function testLinkCollection() + public function testLinkCollection(): void { $paginator = new LengthAwarePaginator([1, 2], 4, 2, 1); $links = $paginator->linkCollection(); - $this->assertInstanceOf(\Hypervel\Support\Collection::class, $links); + $this->assertInstanceOf(Collection::class, $links); $this->assertGreaterThanOrEqual(3, $links->count()); // prev + pages + next // First link is "Previous" @@ -236,7 +238,7 @@ public function testLinkCollection() $this->assertFalse($last['active']); } - public function testToPrettyJson() + public function testToPrettyJson(): void { $paginator = new LengthAwarePaginator(['item/1', 'item/2'], 2, 2, 1); $results = $paginator->toPrettyJson(); @@ -248,18 +250,59 @@ public function testToPrettyJson() $this->assertStringContainsString(' ', $results); } - public function testPreviousPageUrlReturnsNullOnFirstPage() + public function testPreviousPageUrlReturnsNullOnFirstPage(): void { $paginator = new LengthAwarePaginator([1, 2], 10, 2, 1); $this->assertNull($paginator->previousPageUrl()); } - public function testFirstItemAndLastItemReturnNullWhenEmpty() + public function testFirstItemAndLastItemReturnNullWhenEmpty(): void { $paginator = new LengthAwarePaginator([], 0, 2, 1); $this->assertNull($paginator->firstItem()); $this->assertNull($paginator->lastItem()); } + + public function testZeroPerPageKeepsThePublicValueAndCalculatesTheLastPage(): void + { + $paginator = new LengthAwarePaginator([], 4, 0); + + $this->assertSame(0, $paginator->perPage()); + $this->assertSame(4, $paginator->lastPage()); + } + + public function testExplicitZeroPageDoesNotConsultTheResolver(): void + { + Paginator::currentPageResolver(fn () => 9); + + $this->assertSame(1, (new LengthAwarePaginator([], 0, 1, 0))->currentPage()); + $this->assertSame(9, (new LengthAwarePaginator([], 0, 1))->currentPage()); + } + + public function testLengthAwarePaginatorJsonThrowsForInvalidUtf8(): void + { + $paginator = new LengthAwarePaginator(["\xB1\x31"], 1, 1); + + $this->expectException(JsonException::class); + + $paginator->toJson(); + } + + public function testLengthAwarePaginatorPrettyJsonPropagatesInvalidUtf8Failure(): void + { + $paginator = new LengthAwarePaginator(["\xB1\x31"], 1, 1); + + $this->expectException(JsonException::class); + + $paginator->toPrettyJson(); + } + + public function testLengthAwarePaginatorJsonHonorsInvalidUtf8Substitution(): void + { + $paginator = new LengthAwarePaginator(["\xB1\x31"], 1, 1); + + $this->assertStringContainsString('\ufffd1', $paginator->toJson(JSON_INVALID_UTF8_SUBSTITUTE)); + } } diff --git a/tests/Pagination/PackageMetadataTest.php b/tests/Pagination/PackageMetadataTest.php new file mode 100644 index 000000000..0248eae91 --- /dev/null +++ b/tests/Pagination/PackageMetadataTest.php @@ -0,0 +1,42 @@ +assertSame( + [PaginationServiceProvider::class], + $composer['extra']['hypervel']['providers'] + ); + $this->assertContains( + PaginationServiceProvider::class, + $rootComposer['extra']['hypervel']['providers'] + ); + } +} diff --git a/tests/Pagination/PaginationResolverTest.php b/tests/Pagination/PaginationResolverTest.php index 3afbda41a..98397589d 100644 --- a/tests/Pagination/PaginationResolverTest.php +++ b/tests/Pagination/PaginationResolverTest.php @@ -5,15 +5,19 @@ namespace Hypervel\Tests\Pagination; use Hypervel\Context\RequestContext; +use Hypervel\Contracts\View\Factory; use Hypervel\Http\Request; +use Hypervel\Pagination\AbstractCursorPaginator; +use Hypervel\Pagination\AbstractPaginator; use Hypervel\Pagination\Cursor; use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\PaginationState; use Hypervel\Pagination\Paginator; use Hypervel\Testbench\TestCase; -use Swoole\Coroutine\Channel; +use Mockery as m; +use ReflectionProperty; -use function Hypervel\Coroutine\go; +use function Hypervel\Coroutine\parallel; /** * Tests that pagination resolvers work correctly with Swoole's coroutine architecture. @@ -92,6 +96,15 @@ public function testCurrentCursorResolverReturnsNullForInvalidCursor(): void $this->assertNull(CursorPaginator::resolveCurrentCursor()); } + public function testCurrentCursorResolverReturnsNullForArrayInput(): void + { + $this->setUpMockRequest(['cursor' => ['invalid']]); + + PaginationState::resolveUsing($this->app); + + $this->assertNull(CursorPaginator::resolveCurrentCursor()); + } + public function testCurrentPathResolverReadsFromRequest(): void { $this->setUpMockRequest([], 'https://example.com/users'); @@ -128,33 +141,57 @@ public function testQueryStringResolverReturnsEmptyArrayWhenNoRequest(): void $this->assertSame([], Paginator::resolveQueryString()); } - public function testCoroutineIsolation(): void + public function testRequestResolversUseRequestContextInsteadOfTheContainerBinding(): void + { + $cursor = new Cursor(['id' => 42]); + $this->app->instance('request', Request::create('https://container.example?cursor=invalid&page=9')); + RequestContext::set(Request::create( + 'https://context.example/users?cursor=' . $cursor->encode() . '&page=4&sort=name' + )); + + PaginationState::resolveUsing($this->app); + + $this->assertSame('https://context.example/users', Paginator::resolveCurrentPath()); + $this->assertSame(4, Paginator::resolveCurrentPage()); + $this->assertSame([ + 'cursor' => $cursor->encode(), + 'page' => '4', + 'sort' => 'name', + ], Paginator::resolveQueryString()); + $this->assertSame(42, CursorPaginator::resolveCurrentCursor()?->parameter('id')); + } + + public function testViewFactoryResolverHonorsLazyContainerRebinding(): void { PaginationState::resolveUsing($this->app); - $channel = new Channel(2); + $factory = m::mock(Factory::class); + $this->app->instance('view', $factory); - // Coroutine 1: page 5 - go(function () use ($channel) { - $this->setUpMockRequest(['page' => '5']); - $channel->push(['coroutine' => 1, 'page' => Paginator::resolveCurrentPage()]); - }); + $this->assertSame($factory, Paginator::viewFactory()); + } - // Coroutine 2: page 10 - go(function () use ($channel) { - $this->setUpMockRequest(['page' => '10']); - $channel->push(['coroutine' => 2, 'page' => Paginator::resolveCurrentPage()]); - }); + public function testCoroutineIsolation(): void + { + PaginationState::resolveUsing($this->app); + + [$firstPage, $secondPage] = parallel([ + function (): int { + $this->setUpMockRequest(['page' => '5']); + usleep(5000); - $results = []; - $results[] = $channel->pop(1.0); - $results[] = $channel->pop(1.0); + return Paginator::resolveCurrentPage(); + }, + function (): int { + $this->setUpMockRequest(['page' => '10']); + usleep(5000); - // Sort by coroutine number for consistent assertion - usort($results, fn ($a, $b) => $a['coroutine'] <=> $b['coroutine']); + return Paginator::resolveCurrentPage(); + }, + ]); - $this->assertSame(5, $results[0]['page']); - $this->assertSame(10, $results[1]['page']); + $this->assertSame(5, $firstPage); + $this->assertSame(10, $secondPage); } public function testCursorCoroutineIsolation(): void @@ -164,33 +201,28 @@ public function testCursorCoroutineIsolation(): void $cursor1 = new Cursor(['id' => 100], true); $cursor2 = new Cursor(['id' => 200], false); - $channel = new Channel(2); - - go(function () use ($channel, $cursor1) { - $this->setUpMockRequest(['cursor' => $cursor1->encode()]); - $resolved = CursorPaginator::resolveCurrentCursor(); - $channel->push([ - 'coroutine' => 1, - 'id' => $resolved->parameter('id'), - 'pointsToNext' => $resolved->pointsToNextItems(), - ]); - }); - - go(function () use ($channel, $cursor2) { - $this->setUpMockRequest(['cursor' => $cursor2->encode()]); - $resolved = CursorPaginator::resolveCurrentCursor(); - $channel->push([ - 'coroutine' => 2, - 'id' => $resolved->parameter('id'), - 'pointsToNext' => $resolved->pointsToNextItems(), - ]); - }); - - $results = []; - $results[] = $channel->pop(1.0); - $results[] = $channel->pop(1.0); - - usort($results, fn ($a, $b) => $a['coroutine'] <=> $b['coroutine']); + $results = parallel([ + function () use ($cursor1): array { + $this->setUpMockRequest(['cursor' => $cursor1->encode()]); + usleep(5000); + $resolved = CursorPaginator::resolveCurrentCursor(); + + return [ + 'id' => $resolved->parameter('id'), + 'pointsToNext' => $resolved->pointsToNextItems(), + ]; + }, + function () use ($cursor2): array { + $this->setUpMockRequest(['cursor' => $cursor2->encode()]); + usleep(5000); + $resolved = CursorPaginator::resolveCurrentCursor(); + + return [ + 'id' => $resolved->parameter('id'), + 'pointsToNext' => $resolved->pointsToNextItems(), + ]; + }, + ]); $this->assertSame(100, $results[0]['id']); $this->assertTrue($results[0]['pointsToNext']); @@ -198,6 +230,39 @@ public function testCursorCoroutineIsolation(): void $this->assertFalse($results[1]['pointsToNext']); } + public function testFlushStateRestoresEveryStaticPaginationSetting(): void + { + $factory = m::mock(Factory::class); + $cursor = new Cursor(['id' => 10]); + + Paginator::currentPathResolver(fn () => '/custom'); + Paginator::currentPageResolver(fn () => 9); + Paginator::queryStringResolver(fn () => ['sort' => 'name']); + Paginator::viewFactoryResolver(fn () => $factory); + Paginator::defaultView('pagination::custom'); + Paginator::defaultSimpleView('pagination::simple-custom'); + CursorPaginator::currentCursorResolver(fn () => $cursor); + + $this->assertSame('/custom', Paginator::resolveCurrentPath()); + $this->assertSame(9, Paginator::resolveCurrentPage()); + $this->assertSame(['sort' => 'name'], Paginator::resolveQueryString()); + $this->assertSame($factory, Paginator::viewFactory()); + $this->assertSame('pagination::custom', Paginator::$defaultView); + $this->assertSame('pagination::simple-custom', Paginator::$defaultSimpleView); + $this->assertSame($cursor, CursorPaginator::resolveCurrentCursor()); + + AbstractPaginator::flushState(); + AbstractCursorPaginator::flushState(); + + foreach (['currentPathResolver', 'currentPageResolver', 'queryStringResolver', 'viewFactoryResolver'] as $property) { + $this->assertNull((new ReflectionProperty(AbstractPaginator::class, $property))->getValue()); + } + + $this->assertSame('pagination::tailwind', Paginator::$defaultView); + $this->assertSame('pagination::simple-tailwind', Paginator::$defaultSimpleView); + $this->assertNull((new ReflectionProperty(AbstractCursorPaginator::class, 'currentCursorResolver'))->getValue()); + } + /** * Set up a request in Context with the given query parameters. */ diff --git a/tests/Pagination/PaginatorLoadMorphCountTest.php b/tests/Pagination/PaginatorLoadMorphCountTest.php index 42fee2029..0bfa96db4 100644 --- a/tests/Pagination/PaginatorLoadMorphCountTest.php +++ b/tests/Pagination/PaginatorLoadMorphCountTest.php @@ -12,7 +12,7 @@ class PaginatorLoadMorphCountTest extends TestCase { - public function testCollectionLoadMorphCountCanChainOnThePaginator() + public function testCollectionLoadMorphCountCanChainOnThePaginator(): void { $relations = [ 'App\User' => 'photos', diff --git a/tests/Pagination/PaginatorLoadMorphTest.php b/tests/Pagination/PaginatorLoadMorphTest.php index 1b369d132..ab18ebe2d 100644 --- a/tests/Pagination/PaginatorLoadMorphTest.php +++ b/tests/Pagination/PaginatorLoadMorphTest.php @@ -12,7 +12,7 @@ class PaginatorLoadMorphTest extends TestCase { - public function testCollectionLoadMorphCanChainOnThePaginator() + public function testCollectionLoadMorphCanChainOnThePaginator(): void { $relations = [ 'App\User' => 'photos', diff --git a/tests/Pagination/PaginatorResourceTest.php b/tests/Pagination/PaginatorResourceTest.php index 0eb43e865..9188c60f8 100644 --- a/tests/Pagination/PaginatorResourceTest.php +++ b/tests/Pagination/PaginatorResourceTest.php @@ -12,7 +12,7 @@ class PaginatorResourceTest extends TestCase { - public function testItCanTransformToExplicitResource() + public function testItCanTransformToExplicitResource(): void { $paginator = new PaginatorResourceTestPaginator([ new PaginatorResourceTestModel, @@ -23,7 +23,7 @@ public function testItCanTransformToExplicitResource() $this->assertInstanceOf(JsonResource::class, $resource); } - public function testItThrowsExceptionWhenResourceCannotBeFound() + public function testItThrowsExceptionWhenResourceCannotBeFound(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage('Failed to find resource class for model [Hypervel\Tests\Pagination\Fixtures\Models\PaginatorResourceTestModel].'); @@ -35,7 +35,7 @@ public function testItThrowsExceptionWhenResourceCannotBeFound() $paginator->toResourceCollection(); } - public function testItCanGuessResourceWhenNotProvided() + public function testItCanGuessResourceWhenNotProvided(): void { $paginator = new PaginatorResourceTestPaginator([ new PaginatorResourceTestModel, diff --git a/tests/Pagination/PaginatorTest.php b/tests/Pagination/PaginatorTest.php index 26043d413..e1fc5b42f 100644 --- a/tests/Pagination/PaginatorTest.php +++ b/tests/Pagination/PaginatorTest.php @@ -7,10 +7,11 @@ use Hypervel\Pagination\Paginator; use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; +use JsonException; class PaginatorTest extends TestCase { - public function testSimplePaginatorReturnsRelevantContextInformation() + public function testSimplePaginatorReturnsRelevantContextInformation(): void { /** @var Paginator $p */ $p = new Paginator(['item3', 'item4', 'item5'], 2, 2); @@ -36,35 +37,35 @@ public function testSimplePaginatorReturnsRelevantContextInformation() $this->assertEquals($pageInfo, $p->toArray()); } - public function testPaginatorRemovesTrailingSlashes() + public function testPaginatorRemovesTrailingSlashes(): void { $p = new Paginator(['item1', 'item2', 'item3'], 2, 2, ['path' => 'http://website.com/test/']); $this->assertSame('http://website.com/test?page=1', $p->previousPageUrl()); } - public function testPaginatorGeneratesUrlsWithoutTrailingSlash() + public function testPaginatorGeneratesUrlsWithoutTrailingSlash(): void { $p = new Paginator(['item1', 'item2', 'item3'], 2, 2, ['path' => 'http://website.com/test']); $this->assertSame('http://website.com/test?page=1', $p->previousPageUrl()); } - public function testItRetrievesThePaginatorOptions() + public function testItRetrievesThePaginatorOptions(): void { $p = new Paginator(['item1', 'item2', 'item3'], 2, 2, ['path' => 'http://website.com/test']); $this->assertSame(['path' => 'http://website.com/test'], $p->getOptions()); } - public function testPaginatorReturnsPath() + public function testPaginatorReturnsPath(): void { $p = new Paginator(['item1', 'item2', 'item3'], 2, 2, ['path' => 'http://website.com/test']); $this->assertSame('http://website.com/test', $p->path()); } - public function testCanTransformPaginatorItems() + public function testCanTransformPaginatorItems(): void { $p = new Paginator(['item1', 'item2', 'item3'], 3, 1, ['path' => 'http://website.com/test']); @@ -76,7 +77,7 @@ public function testCanTransformPaginatorItems() $this->assertSame(['1', '2', '3'], $p->items()); } - public function testPaginatorToJson() + public function testPaginatorToJson(): void { $p = new Paginator(['item1', 'item2', 'item3'], 3, 1); $results = $p->toJson(); @@ -86,7 +87,7 @@ public function testPaginatorToJson() $this->assertSame($expected, $results); } - public function testPaginatorToPrettyJson() + public function testPaginatorToPrettyJson(): void { $p = new Paginator(['item/1', 'item/2', 'item/3'], 3, 1); $results = $p->toPrettyJson(); @@ -104,14 +105,14 @@ public function testPaginatorToPrettyJson() $this->assertStringContainsString('item/1', $results); } - public function testPreviousPageUrlReturnsNullOnFirstPage() + public function testPreviousPageUrlReturnsNullOnFirstPage(): void { $p = new Paginator(['item1', 'item2'], 2, 1); $this->assertNull($p->previousPageUrl()); } - public function testFragmentGetAndSet() + public function testFragmentGetAndSet(): void { $p = new Paginator(['item1', 'item2'], 2, 1); @@ -122,7 +123,7 @@ public function testFragmentGetAndSet() $this->assertSame('section', $p->fragment()); } - public function testFragmentAppearsInUrl() + public function testFragmentAppearsInUrl(): void { $p = new Paginator(['item1', 'item2', 'item3'], 2, 1); $p->fragment('top'); @@ -130,7 +131,7 @@ public function testFragmentAppearsInUrl() $this->assertSame('/?page=2#top', $p->url(2)); } - public function testIsEmptyAndIsNotEmpty() + public function testIsEmptyAndIsNotEmpty(): void { $p = new Paginator([], 2, 1); $this->assertTrue($p->isEmpty()); @@ -141,14 +142,14 @@ public function testIsEmptyAndIsNotEmpty() $this->assertTrue($p->isNotEmpty()); } - public function testCount() + public function testCount(): void { $p = new Paginator(['item1', 'item2', 'item3'], 3, 1); $this->assertSame(3, $p->count()); } - public function testGetCollectionAndSetCollection() + public function testGetCollectionAndSetCollection(): void { $p = new Paginator(['item1', 'item2'], 2, 1); @@ -162,7 +163,7 @@ public function testGetCollectionAndSetCollection() $this->assertSame(['a', 'b', 'c'], $p->items()); } - public function testArrayAccess() + public function testArrayAccess(): void { $p = new Paginator(['item1', 'item2', 'item3'], 3, 1); @@ -184,7 +185,7 @@ public function testArrayAccess() $this->assertFalse(isset($p[0])); } - public function testWithPathIsFluent() + public function testWithPathIsFluent(): void { $p = new Paginator(['item1'], 1, 1); @@ -193,7 +194,7 @@ public function testWithPathIsFluent() $this->assertSame('http://example.com/items', $p->path()); } - public function testGetUrlRange() + public function testGetUrlRange(): void { $p = new Paginator(['item1', 'item2'], 2, 1); @@ -205,7 +206,7 @@ public function testGetUrlRange() ], $range); } - public function testHasMorePagesWhen() + public function testHasMorePagesWhen(): void { $p = new Paginator(['item1', 'item2'], 2, 1); @@ -218,7 +219,7 @@ public function testHasMorePagesWhen() $this->assertFalse($p->hasMorePages()); } - public function testEscapeWhenCastingToString() + public function testEscapeWhenCastingToString(): void { $p = new Paginator(['item1'], 1, 1); @@ -226,7 +227,7 @@ public function testEscapeWhenCastingToString() $this->assertSame($p, $result); } - public function testWithQueryString() + public function testWithQueryString(): void { Paginator::queryStringResolver(fn () => ['sort' => 'name', 'direction' => 'asc']); @@ -235,4 +236,54 @@ public function testWithQueryString() $this->assertSame('/?sort=name&direction=asc&page=2', $p->url(2)); } + + public function testAppendsPreservesIntegerKeysAndSupportedValues(): void + { + $paginator = new Paginator(['item1', 'item2'], 1); + + $paginator->appends([ + 2 => 'two', + 5 => 4.25, + 'enabled' => true, + 'filters' => ['status' => 'active'], + ]); + + $this->assertSame( + '/?2=two&5=4.25&enabled=1&filters%5Bstatus%5D=active&page=2', + $paginator->url(2), + ); + } + + public function testExplicitZeroPageDoesNotConsultTheResolver(): void + { + Paginator::currentPageResolver(fn () => 9); + + $this->assertSame(1, (new Paginator(['item'], 1, 0))->currentPage()); + $this->assertSame(9, (new Paginator(['item'], 1))->currentPage()); + } + + public function testPaginatorJsonThrowsForInvalidUtf8(): void + { + $paginator = new Paginator(["\xB1\x31"], 1); + + $this->expectException(JsonException::class); + + $paginator->toJson(); + } + + public function testPaginatorPrettyJsonPropagatesInvalidUtf8Failure(): void + { + $paginator = new Paginator(["\xB1\x31"], 1); + + $this->expectException(JsonException::class); + + $paginator->toPrettyJson(); + } + + public function testPaginatorJsonHonorsInvalidUtf8Substitution(): void + { + $paginator = new Paginator(["\xB1\x31"], 1); + + $this->assertStringContainsString('\ufffd1', $paginator->toJson(JSON_INVALID_UTF8_SUBSTITUTE)); + } } diff --git a/tests/Pagination/UrlWindowTest.php b/tests/Pagination/UrlWindowTest.php index 582a5cd7d..4858c7ea6 100644 --- a/tests/Pagination/UrlWindowTest.php +++ b/tests/Pagination/UrlWindowTest.php @@ -10,21 +10,21 @@ class UrlWindowTest extends TestCase { - public function testPresenterCanDetermineIfThereAreAnyPagesToShow() + public function testPresenterCanDetermineIfThereAreAnyPagesToShow(): void { $p = new LengthAwarePaginator($array = ['item1', 'item2', 'item3', 'item4'], 4, 2, 2); $window = new UrlWindow($p); $this->assertTrue($window->hasPages()); } - public function testPresenterCanGetAUrlRangeForASmallNumberOfUrls() + public function testPresenterCanGetAUrlRangeForASmallNumberOfUrls(): void { $p = new LengthAwarePaginator($array = ['item1', 'item2', 'item3', 'item4'], 4, 2, 2); $window = new UrlWindow($p); $this->assertEquals(['first' => [1 => '/?page=1', 2 => '/?page=2'], 'slider' => null, 'last' => null], $window->get()); } - public function testPresenterCanGetAUrlRangeForAWindowOfLinks() + public function testPresenterCanGetAUrlRangeForAWindowOfLinks(): void { $array = []; for ($i = 1; $i <= 20; ++$i) { @@ -53,7 +53,7 @@ public function testPresenterCanGetAUrlRangeForAWindowOfLinks() $this->assertEquals(['first' => [1 => '/?page=1', 2 => '/?page=2'], 'slider' => null, 'last' => $last], $window->get()); } - public function testCustomUrlRangeForAWindowOfLinks() + public function testCustomUrlRangeForAWindowOfLinks(): void { $array = []; for ($i = 1; $i <= 20; ++$i) { diff --git a/tests/Sanctum/NewAccessTokenTest.php b/tests/Sanctum/NewAccessTokenTest.php index da166d785..cdca43474 100644 --- a/tests/Sanctum/NewAccessTokenTest.php +++ b/tests/Sanctum/NewAccessTokenTest.php @@ -7,13 +7,14 @@ use Hypervel\Sanctum\NewAccessToken; use Hypervel\Sanctum\PersonalAccessToken; use Hypervel\Tests\TestCase; +use JsonException; class NewAccessTokenTest extends TestCase { /** * Test to array method. */ - public function testToArrayMethod() + public function testToArrayMethod(): void { $accessToken = new PersonalAccessToken([ 'name' => 'Test Token', @@ -34,7 +35,7 @@ public function testToArrayMethod() /** * Test to json method. */ - public function testToJsonMethod() + public function testToJsonMethod(): void { $accessToken = new PersonalAccessToken([ 'name' => 'Test Token', @@ -57,7 +58,7 @@ public function testToJsonMethod() /** * Test toString method. */ - public function testToStringMethod() + public function testToStringMethod(): void { $accessToken = new PersonalAccessToken([ 'name' => 'Test Token', @@ -72,4 +73,23 @@ public function testToStringMethod() $this->assertJson($string); $this->assertSame($newToken->toJson(), $string); } + + public function testToJsonThrowsForInvalidUtf8(): void + { + $newToken = new NewAccessToken(new PersonalAccessToken, "\xB1\x31"); + + $this->expectException(JsonException::class); + + $newToken->toJson(); + } + + public function testToJsonHonorsInvalidUtf8Substitution(): void + { + $newToken = new NewAccessToken(new PersonalAccessToken, "\xB1\x31"); + + $this->assertStringContainsString( + '"plainTextToken":"\ufffd1"', + $newToken->toJson(JSON_INVALID_UTF8_SUBSTITUTE), + ); + } } diff --git a/tests/Scout/Unit/BuilderTest.php b/tests/Scout/Unit/BuilderTest.php index 3e6889fb0..b9dbd87f2 100644 --- a/tests/Scout/Unit/BuilderTest.php +++ b/tests/Scout/Unit/BuilderTest.php @@ -704,6 +704,40 @@ public function testGenericPaginationUsesFreshContainerSubstitutionsAndDefaultPe $this->assertSame(15, $lengthAwareRaw->perPage()); } + public function testPublicPaginationBoundariesClampInvalidPagesBeforeEngineDispatch(): void + { + Paginator::currentPageResolver(fn () => 3); + Paginator::currentPathResolver(fn () => 'http://localhost/foo'); + + $model = m::mock(Model::class); + $engine = m::mock(Engine::class); + $model->shouldReceive('getPerPage')->times(5)->andReturn(15); + $model->shouldReceive('searchableUsing')->times(8)->andReturn($engine); + $model->shouldReceive('newCollection')->twice()->andReturn(new EloquentCollection); + + $pages = []; + $rawResults = ['hits' => [], 'estimatedTotalHits' => 0]; + $engine->shouldReceive('paginate')->times(5)->andReturnUsing( + function (Builder $_, int $perPage, int $page) use (&$pages, $rawResults): array { + $this->assertSame(15, $perPage); + $pages[] = $page; + + return $rawResults; + } + ); + $engine->shouldReceive('map')->twice()->andReturn(new EloquentCollection); + $engine->shouldReceive('getTotalCount')->times(5)->andReturn(0); + + $builder = new Builder($model, 'query'); + + $this->assertSame(1, $builder->simplePaginate(page: 0)->currentPage()); + $this->assertSame(1, $builder->paginate(page: -2)->currentPage()); + $this->assertSame(1, $builder->paginateRaw(page: 0)->currentPage()); + $this->assertSame(1, $builder->simplePaginateRaw(page: -2)->currentPage()); + $this->assertSame(3, $builder->paginateRaw()->currentPage()); + $this->assertSame([1, 1, 1, 1, 3], $pages); + } + public function testMacroable(): void { Builder::macro('testMacro', function () { diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 7b61e03b4..da2794ae3 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -21,6 +21,7 @@ use Hypervel\Support\Stringable; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use JsonException; use JsonSerializable; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -760,6 +761,28 @@ public function testToPrettyJsonEncodesTheJsonSerializeResult($collection): void $this->assertStringContainsString(' ', $results); } + #[DataProvider('collectionClassProvider')] + public function testToJsonThrowsForInvalidUtf8($collection): void + { + $this->expectException(JsonException::class); + + (new $collection(["\xB1\x31"]))->toJson(); + } + + #[DataProvider('collectionClassProvider')] + public function testToPrettyJsonPropagatesInvalidUtf8Failure($collection): void + { + $this->expectException(JsonException::class); + + (new $collection(["\xB1\x31"]))->toPrettyJson(); + } + + #[DataProvider('collectionClassProvider')] + public function testToJsonHonorsInvalidUtf8Substitution($collection): void + { + $this->assertSame('["\ufffd1"]', (new $collection(["\xB1\x31"]))->toJson(JSON_INVALID_UTF8_SUBSTITUTE)); + } + #[DataProvider('collectionClassProvider')] public function testCastingToStringJsonEncodesTheToArrayResult($collection): void { diff --git a/tests/Support/SupportFluentTest.php b/tests/Support/SupportFluentTest.php index 3111c3fe7..ed85cd795 100644 --- a/tests/Support/SupportFluentTest.php +++ b/tests/Support/SupportFluentTest.php @@ -12,6 +12,7 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use IteratorAggregate; +use JsonException; use ReflectionObject; include_once __DIR__ . '/Enums.php'; @@ -147,6 +148,27 @@ public function testToPrettyJson() $this->assertStringContainsString(' ', $results); } + public function testToJsonThrowsForInvalidUtf8(): void + { + $this->expectException(JsonException::class); + + (new Fluent(['value' => "\xB1\x31"]))->toJson(); + } + + public function testToPrettyJsonPropagatesInvalidUtf8Failure(): void + { + $this->expectException(JsonException::class); + + (new Fluent(['value' => "\xB1\x31"]))->toPrettyJson(); + } + + public function testToJsonHonorsInvalidUtf8Substitution(): void + { + $fluent = new Fluent(['value' => "\xB1\x31"]); + + $this->assertSame('{"value":"\ufffd1"}', $fluent->toJson(JSON_INVALID_UTF8_SUBSTITUTE)); + } + public function testScope() { $fluent = new Fluent(['user' => ['name' => 'taylor']]); diff --git a/tests/Support/SupportMessageBagTest.php b/tests/Support/SupportMessageBagTest.php index 308d7bbd9..2f7b32b58 100755 --- a/tests/Support/SupportMessageBagTest.php +++ b/tests/Support/SupportMessageBagTest.php @@ -7,6 +7,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\MessageBag; use Hypervel\Tests\TestCase; +use JsonException; class SupportMessageBagTest extends TestCase { @@ -280,6 +281,31 @@ public function testMessageBagReturnsExpectedPrettyJson() $this->assertStringNotContainsString('"123"', $results); } + public function testMessageBagJsonThrowsForInvalidUtf8(): void + { + $container = new MessageBag(['value' => "\xB1\x31"]); + + $this->expectException(JsonException::class); + + $container->toJson(); + } + + public function testMessageBagPrettyJsonPropagatesInvalidUtf8Failure(): void + { + $container = new MessageBag(['value' => "\xB1\x31"]); + + $this->expectException(JsonException::class); + + $container->toPrettyJson(); + } + + public function testMessageBagJsonHonorsInvalidUtf8Substitution(): void + { + $container = new MessageBag(['value' => "\xB1\x31"]); + + $this->assertSame('{"value":["\ufffd1"]}', $container->toJson(JSON_INVALID_UTF8_SUBSTITUTE)); + } + public function testCountReturnsCorrectValue() { $container = new MessageBag; diff --git a/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index 222a9cb03..db36c4816 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -74,6 +74,8 @@ function test( assertType('Hypervel\Support\LazyCollection', $query->lazy()); assertType('Hypervel\Support\LazyCollection', $query->lazyById()); assertType('Hypervel\Support\LazyCollection', $query->lazyByIdDesc()); + assertType('Hypervel\Pagination\CursorPaginator', $query->cursorPaginate()); + assertType('string', $query->cursorPaginate()->toJson()); assertType('Hypervel\Support\Collection<(int|string), mixed>', $query->pluck('foo')); assertType('Hypervel\Database\Eloquent\Relations\Relation', $query->getRelation('foo')); assertType('Hypervel\Database\Eloquent\Builder', $query->setModel(new Post)); diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 7f2f6ef08..90ce2abbb 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -16,6 +16,7 @@ use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Eloquent\Relations\MorphToMany; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Pagination\Cursor; use function PHPStan\Testing\assertType; @@ -80,6 +81,9 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Support\LazyCollection', $user->roles()->lazy()); assertType('Hypervel\Support\LazyCollection', $user->roles()->lazyById()); assertType('Hypervel\Support\LazyCollection', $user->roles()->cursor()); + assertType('Hypervel\Pagination\LengthAwarePaginator', $user->roles()->paginate()); + assertType('Hypervel\Contracts\Pagination\Paginator', $user->roles()->simplePaginate()); + assertType('Hypervel\Contracts\Pagination\CursorPaginator', $user->roles()->cursorPaginate(cursor: new Cursor(['id' => 1]))); assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->car()); assertType('Hypervel\Types\Relations\Car|null', $user->car()->getResults()); @@ -98,6 +102,9 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Relations\HasManyThrough', $user->parts()); assertType('Hypervel\Database\Eloquent\Collection', $user->parts()->getResults()); + assertType('Hypervel\Pagination\LengthAwarePaginator', $user->parts()->paginate()); + assertType('Hypervel\Contracts\Pagination\Paginator', $user->parts()->simplePaginate()); + assertType('Hypervel\Contracts\Pagination\CursorPaginator', $user->parts()->cursorPaginate(cursor: new Cursor(['id' => 1]))); assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->firstPart()); assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $post->user()); diff --git a/types/Database/Query/Builder.php b/types/Database/Query/Builder.php index 84d58b19c..bddad410e 100644 --- a/types/Database/Query/Builder.php +++ b/types/Database/Query/Builder.php @@ -39,6 +39,9 @@ function test(Builder $query, EloquentBuilder $userQuery): void assertType('Hypervel\Support\LazyCollection', $query->lazy()); assertType('Hypervel\Support\LazyCollection', $query->lazyById()); assertType('Hypervel\Support\LazyCollection', $query->lazyByIdDesc()); + assertType('Hypervel\Pagination\LengthAwarePaginator', $query->paginate()); + assertType('Hypervel\Contracts\Pagination\Paginator', $query->simplePaginate()); + assertType('Hypervel\Contracts\Pagination\CursorPaginator', $query->cursorPaginate()); $query->chunk(1, function ($users, $page) { assertType('Hypervel\Support\Collection', $users); diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index 5817b9a58..156bbde89 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -14,7 +14,10 @@ $paginator = new Paginator($items, 1, 1); assertType('array', $paginator->items()); -assertType('Traversable', $paginator->getIterator()); +assertType('ArrayIterator', $paginator->getIterator()); + +assertType('string|null', $paginator->fragment()); +assertType('Hypervel\Pagination\Paginator', $paginator->fragment('foo')); $paginator->each(function ($post) { assertType('Post', $post); @@ -28,7 +31,7 @@ $lengthAwarePaginator = new LengthAwarePaginator($items, 1, 1); assertType('array', $lengthAwarePaginator->items()); -assertType('Traversable', $lengthAwarePaginator->getIterator()); +assertType('ArrayIterator', $lengthAwarePaginator->getIterator()); $lengthAwarePaginator->each(function ($post) { assertType('Post', $post); @@ -44,6 +47,9 @@ assertType('array', $cursorPaginator->items()); assertType('ArrayIterator', $cursorPaginator->getIterator()); +assertType('string|null', $cursorPaginator->fragment()); +assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginator->fragment('foo')); + $cursorPaginator->each(function ($post) { assertType('Post', $post); }); diff --git a/types/Routing/Route.php b/types/Routing/Route.php new file mode 100644 index 000000000..7982c4e01 --- /dev/null +++ b/types/Routing/Route.php @@ -0,0 +1,18 @@ +middleware()); +assertType(Route::class, RouteFacade::get('/')->middleware('auth')); +assertType(Route::class, RouteFacade::get('/')->middleware(['auth'])); + +assertType('string|null', RouteFacade::get('/')->domain()); +assertType(Route::class, RouteFacade::get('/')->domain('example.com')); + +assertType('array', RouteFacade::get('/')->getMetadata()); +assertType('mixed', RouteFacade::get('/')->getMetadata('key')); diff --git a/types/Support/Lottery.php b/types/Support/Lottery.php new file mode 100644 index 000000000..e34b2d55b --- /dev/null +++ b/types/Support/Lottery.php @@ -0,0 +1,12 @@ +choose()); +assertType('list', $lottery->choose(2));