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 0378b2179..6cb60a80c 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,7 +990,7 @@ 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 — Reverb is complete and the next package is not selected. +- **Active package or work unit:** none — Scout is complete and the next package is not selected. - **Ledger entries required for the active work:** none. - **Pending revalidation carried into the active work:** none. @@ -1076,6 +1076,8 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `http-03` | `http`, `foundation` | `contracts` and `foundation` (revalidation complete); later full `http` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `http-03` | | `auth-02` | `auth` | `foundation` (revalidation complete); later full `auth` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `auth-02` | | `database-03` | `database` | `foundation` and `database` (revalidation complete); later full `testbench` audit | `Complete Foundation runtime lifecycles and safe publication`; finding `database-03` | +| `foundation-17` | `foundation` | `foundation` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `foundation-17` | +| `foundation-18` | `foundation` | `foundation` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `foundation-18` | | `database-04` | `database` | `console` and `database` (revalidation complete) | `Complete Console command, scheduling, and generator lifecycles`; finding `database-04` | | `reverb-04` | `reverb` | `reverb` (revalidation complete) | `Complete Console command, scheduling, and generator lifecycles`; finding `reverb-04` | | `watcher-10` | `support` | `watcher`, `foundation`, and `horizon` (revalidation complete) | `Make Watcher drivers and managed processes lifecycle-safe`; finding `watcher-10` | @@ -1149,6 +1151,9 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `http-06` | `http` | `http` (targeted correction complete); later full `http` audit | `Harden Eloquent identity and partial-projection safety`; finding `http-06` | | `testing-01` | `testing` | `testing` (targeted correction complete); later full `testing` audit | `Harden Eloquent identity and partial-projection safety`; finding `testing-01` | | `testing-02` | `testing` | `testing` (targeted correction complete); later full `testing` audit | `Harden Eloquent identity and partial-projection safety`; finding `testing-02` | +| `database-21` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-21` | +| `database-22` | `database` | `database` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-22` | +| `database-23` | `database` | `database` and `scout` (revalidation complete) | `Complete Scout current parity, queue, and search lifecycles`; finding `database-23` | ## Package checklist @@ -1245,7 +1250,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [ ] `passkeys` - [ ] `permission` - [ ] `jwt` -- [ ] `scout` +- [x] `scout` - [ ] `telescope` - [ ] `sentry` - [ ] `inertia` 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 3b01d9e8b..fb5a50eaf 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 @@ -979,10 +979,12 @@ Append package entries in checklist order. Keep each entry compact but complete | `auth-02` | Parity defect | Major | High | Explicit nullable guest redirects cannot be represented and a null target reaches redirect construction | Make guest redirects genuinely nullable through Auth/Foundation/facade surfaces and return a no-content 401 when no redirect resolves | | `database-03` | Defect and parity defect | Major | High | Lazy refresh hooks run in the wrong coroutine/lifetime, runtime opt-out is ignored, and current assertion shapes are missing | Use the actual runtime coroutine flag, install hooks on every owned connection, make refresh state exception-safe, and port current multiple-row, iterable, and connection-aware assertions | | `foundation-16` | Package metadata and documentation defect | Major | High | Foundation's split package omits direct dependencies and provenance while several new public APIs and runtime limitations are undiscoverable | Declare only direct split dependencies, add focused metadata coverage and provenance, and update seven task-first user guides plus the Kernel contract note | +| `foundation-17` | Integration-test harness defect | Major | High | Foundation's Meilisearch waiter discards failed task results and cleanup returns before its asynchronous index deletions finish, allowing consumers to report success after failed work or race the next test | Check every awaited result, propagate failures, wait for exact cleanup tasks, and keep Foundation as the single owner | +| `foundation-18` | Integration-test harness defect | Major | High | Foundation's Algolia cleanup returns before its asynchronous index deletions finish, allowing the next test to reuse an index while deletion remains queued | Wait for each exact index-deletion task before setup or teardown continues | - **Approved owner gates:** The owner approved the process-local/testing-only array maintenance driver, nullable redirect and current Laravel API additions, the truthful `Http\Kernel` contract expansion required by middleware configuration, the `0600` default for newly decrypted plaintext, and correcting verified upstream defects rather than preserving parity bugs. The Kernel contract change is documented for custom implementations. The existing worker maintenance wrapper remains a periodic same-process snapshot and does not make the array driver cross-process. - **Important rejected concerns:** Do not add shutdown-callback registries, PHPUnit meta-fixtures, a generic finalizer, request-scoped Application/Vite clones, Carbon managers, locks, watchers, retry loops, PID incarnation tracking, publication/delete transaction services, arbitrary-stream copy transactions, SQL parsers, TTY emulation, or broad PHPStan impurity annotations. The supported paths are covered by existing lifecycle, coroutine-context, Filesystem replacement, Process, and typed-reflection primitives. Retain `Filesystem::delete()` cache clearing because the full caller set and PHP stat cache require it; use targeted cache invalidation only where raw command/native postconditions demand it. -- **Cross-package implications and revalidation:** Foundation revalidated all carried `view-01`, `testbench-01`, `reflection-01`, `reflection-02`, `config-01`, `config-02`, `context-01`, `context-04`, `coroutine-06`, `foundation-02`, `concurrency-01`, `concurrency-03`, `di-02`, `http-02`, `filesystem-07`, `foundation-04`, `events-01`, `events-04`, `events-06`, `foundation-01`, `support-02`, `encryption-03`, `server-process-10`, `bus-03`, `bus-17`, `bus-18`, `core-01`, and `core-05` assumptions. The later Queue work completed `queue-14` and revalidated Foundation's canonical base queue configuration under `queue-29`. The Reverb work completed `reverb-24` by adding the supported server path to Foundation's canonical broadcasting client options with focused config coverage; no Foundation runtime assumption changed. `foundation-06` requires later full Testbench revalidation; `http-03` requires the HTTP audit; `auth-02` requires the Auth audit; and `database-03` requires the Database and Testbench audits. +- **Cross-package implications and revalidation:** Foundation revalidated all carried `view-01`, `testbench-01`, `reflection-01`, `reflection-02`, `config-01`, `config-02`, `context-01`, `context-04`, `coroutine-06`, `foundation-02`, `concurrency-01`, `concurrency-03`, `di-02`, `http-02`, `filesystem-07`, `foundation-04`, `events-01`, `events-04`, `events-06`, `foundation-01`, `support-02`, `encryption-03`, `server-process-10`, `bus-03`, `bus-17`, `bus-18`, `core-01`, and `core-05` assumptions. The later Queue work completed `queue-14` and revalidated Foundation's canonical base queue configuration under `queue-29`. The Reverb work completed `reverb-24` by adding the supported server path to Foundation's canonical broadcasting client options with focused config coverage; no Foundation runtime assumption changed. The Scout work adds `foundation-17` and `foundation-18`: Scout delegates to Foundation's truthful Meilisearch waiter, deletes its duplicate Support implementation, uses a service-valid custom key with exact remote precondition coverage, and inherits exact Meilisearch and Algolia cleanup-task ownership. `foundation-06` requires later full Testbench revalidation; `http-03` requires the HTTP audit; `auth-02` requires the Auth audit; and `database-03` requires the Database and Testbench audits. - **Upstream and documentation:** Originating Laravel implementation and documentation pull requests supplied discovery history; current local Laravel default-branch source, tests, metadata, and docs supplied the porting reference. Hypervel deliberately retains its Swoole exception renderer, coroutine-aware test lifecycle, worker-cached maintenance model, and checked TTY behavior. Authentication, configuration, database testing, deployment, HTTP testing, middleware, and queue documentation now covers every new public call shape and important runtime limitation without exposing internal choreography. - **Implementation:** Foundation/Testbench teardown now exhausts every independent owner and clears state exactly once. External-service clients, global clocks/environment, and dump recursion are restored at their actual lifetimes. Maintenance drivers, middleware, commands, Vite, renderers, clear/link/cache/environment commands, generators, publishers, and installers check native failures and publish only validated state. Current Laravel dispatch, retry-stopping, preferred-JSON, health, HTTP testing, route-list, nullable redirect, and database-testing APIs are complete; the narrow retry callback upstream defect is corrected. Dead duplicate Scout setup, stale helpers/properties/imports, unsafe raw boundaries, false success paths, and obsolete comments are removed. - **Regression tests:** Deterministic coverage injects failure into every teardown/termination phase, setup wrapper shape, SDK/clock/dumper/environment restoration, maintenance/cache/file publication, generator/installer/publisher operation, reflection/Blade/native read, health state, retry callback typing, and lazy database refresh boundary. It also covers coroutine isolation, process-local array semantics, current Laravel public API call shapes, custom dispatch construction, Kernel contract consumers, mode preservation, old-artifact survival, and exact throwable precedence. @@ -1162,7 +1164,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. Focused consumer coverage and the complete gate remain 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. Database and Scout 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. @@ -1504,3 +1506,29 @@ Append package entries in checklist order. Keep each entry compact but complete - **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. + +### Complete Scout current parity, queue, and search lifecycles + +- **Status and inspected surface:** Complete; implementation, focused validation, the complete gate, fresh self-review, and independent code review are signed off. The audit covered every Scout source, unit, and integration file; split/root metadata, configuration, README, Boost guide, workflow, and consumers; current Scout 11.x source/tests plus originating PRs #416, #505, #728, #759, #839, #852, #969, #984, #1003, and #1005; Laravel Database; installed Algolia, Meilisearch, Typesense, Guzzle, and PSR HTTP packages; and carried `support-02`, `database-10`, `database-15`–`20`, `queue-41`, `scout-01`, and `scout-02`. The detailed design is recorded in [`2026-08-02-1006-scout-current-parity-queue-and-search-lifecycles.md`](2026-08-02-1006-scout-current-parity-queue-and-search-lifecycles.md). + +| Findings | Final decision | +|---|---| +| `scout-03`, `scout-04`, `scout-29`, `scout-33`, `scout-34` | Port ordered two/three-argument comparisons, every dependent soft-delete consumer, escaped Algolia filters with exact six-operator and scalar validation, exact string-zero behavior, uniform remote backed-enum formatting, and explicit Typesense operator rejection. | +| `scout-05`–`scout-10`, `scout-31`, `scout-32`, `scout-39`, `database-21` | Prevent duplicate force-delete removal; make suppression nesting-safe; use through-aware relation macros, fresh substitutable Builder/paginators, zero-safe per-page defaults, truthful raw Database pagination and model contracts; skip optional-event construction; reject nonpositive Database chunk sizes at the shared owner. | +| `scout-11`, `scout-12`, `scout-16`–`scout-18`, `scout-35` | Restore current job options and unique jobs, exact query-free queued-removal identities, split dependencies, one boolean after-commit setting, and first-failure import propagation with complete active-child drain and context cleanup. | +| `scout-13`, `scout-24`–`scout-28`, `scout-40`, `database-23` | Add validated ascending/descending queue imports for integer and database-ordered string keys with normalized, overflow-safe job bounds; widen Database's descending cursor to its actual string/integer domain; preserve exact zero; keep operational failures visible; and resolve logical settings before physical fallback. | +| `scout-14`, `scout-21`–`scout-23`, `scout-30`, `scout-37` | Separate Typesense read/write targets and creation, classify absence exactly, remove redundant delete/flush/update requests, retry one missing-collection import, and detach short-lived wrappers from the SDK's unbounded worker registry. | +| `scout-15` | Treat only Meilisearch 404 as absence and only `index_already_exists` as the accepted create race. | +| `scout-19`, `scout-20`, `scout-36` | Correct provenance, concise actionable differences, current public docs/CLI language, and the identified Scout test typing. | +| `scout-38`, `database-22` | Instantiate Scout search attributes and Database `CollectedBy` so positional and named arguments share the constructor-owned contract and malformed declarations fail fast. | +| `foundation-17` | Make Foundation's Meilisearch test waiter reject failed awaited tasks and propagate timeouts, and make cleanup wait for its exact deletion tasks; delete Scout's duplicate wait path, use a service-valid custom-key fixture, and document the identifier restriction. | +| `foundation-18` | Make Foundation's Algolia cleanup wait for each exact index-deletion task before setup or teardown continues. | + +- **Worker and operation ownership:** Engines, SDK clients, and reusable transports remain worker-shared. Mutable builders, paginators, import failure state, observer suppression, and detached Typesense wrappers remain operation/coroutine-local. No per-request client, static reset, second cleanup registry, lock, or unbounded retained state is introduced. +- **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. +- **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. +- **Validation and review:** Every changed test file and affected package group passed during implementation. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split metadata, `git diff --check`, package-checklist parity, finding-ID uniqueness, broad stale-reference scans, and a fresh caller/callee, API, coroutine, retained-state, I/O, hot-path, and overengineering review are complete. Independent review re-read the complete diff, verified the final corrections, Database regressions, and Foundation search-task harness changes, and signed off with no remaining findings. diff --git a/docs/plans/2026-08-02-1006-scout-current-parity-queue-and-search-lifecycles.md b/docs/plans/2026-08-02-1006-scout-current-parity-queue-and-search-lifecycles.md new file mode 100644 index 000000000..ebc5d7e40 --- /dev/null +++ b/docs/plans/2026-08-02-1006-scout-current-parity-queue-and-search-lifecycles.md @@ -0,0 +1,404 @@ +# Scout Current Parity, Queue, and Search Lifecycles + +## Status and scope + +**Status:** Complete; implementation, validation, self-review, and independent code review are signed off. + +Complete the Scout audit against current local sources: + +- Hypervel `0.4` at this branch's base; +- Laravel Scout `11.x` at `b197b78`; +- Laravel framework and documentation under `examples/laravel/framework` and `examples/laravel/docs`; +- installed Algolia, Meilisearch, Typesense, Guzzle, and PSR HTTP packages under `vendor/`; +- completed `support-02`, `database-10`, `database-15`–`20`, `queue-41`, `scout-01`, and `scout-02` decisions. + +The work covers Scout source, configuration, split metadata, public Boost documentation, unit and external-service integration tests, plus three defects owned by Database. It must preserve Hypervel's coroutine-aware and higher-scale additions: + +- `collection` as the default engine and `app_id().'_'` as the default prefix; +- structured queue configuration for `enabled`, `connection`, and `queue`; +- coroutine-deferred nonqueued indexing and bounded concurrent command imports; +- worker-shared engines, SDK clients, HTTP transports, and connection reuse; +- explicit Swoole-safe Guzzle clients, bounded Meilisearch retries, and request-context Algolia identify headers; +- Meilisearch tenant-token helpers, transformed collections, `int|string` range keys, rich Typesense configuration, distinct read/write index names, prefix-aware delete-all safety, and configurable job classes. + +Do not port Algolia 3 or replace these adaptations with Laravel's request-per-process internals. + +## Post-compaction and design rules + +After compaction, re-read `AGENTS.md` and this plan in full before editing. Re-open the active source and tests; summaries are navigation only. + +- Require a supported path and meaningful harm before adding machinery. Complexity must pay for itself through a demonstrated failure, a complete trace of a realistic schedule, an approved capability with real consumers, or removal of greater complexity; a merely conceivable state is not enough. +- Fix the lowest owner completely and update every affected caller, contract, test, comment, config key, and document in the same coherent change. This applies to any genuine issue discovered during the work, including another package, once it meets the evidence threshold; do not add consumer workarounds or defer it to preserve package order. +- Hypervel 0.4 has no prior-version compatibility burden, but current Laravel public APIs, configuration, named arguments, and conventional extension points remain the default contract. Do not retain an inferior implementation solely for compatibility: a necessary divergence must have a concrete benefit, owner approval, and a cleaner result than a shim or workaround. +- Do not infer correctness from difference or parity. Trace both implementations and fix a verified defect even when Laravel, an SDK, or another upstream shares it. +- Preserve coroutine isolation and worker-lifetime client reuse. Per-operation mutable state belongs in arguments, fresh objects, or `CoroutineContext`; stable clients and metadata may remain worker-shared. +- Make ownership and failure handoff explicit. Creation is transactional, cleanup is exhaustive, the earliest failure stays primary, and waits are bounded only where progress depends on a peer that can disappear. +- Audit every added allocation, container resolution, lock, hash, serialization, yield, retry, log, network call, and retained worker object. A source-proven or measured hot-path regression requires owner approval. A proposed optimization must have a meaningful practical benefit after complexity and upstream divergence; style churn and benchmark noise are not findings. +- Prefer existing PHP, Laravel, and Hypervel primitives. Treat established remediation patterns as candidates, not automatic answers. Do not add speculative abstractions, registries, locks, caches, retries, timeouts, state machines, factories, context slots, extension points, parsers, compatibility APIs, or configuration. +- Do not add machinery for deliberate framework escape hatches such as raw queries, disabled events, or direct SDK access unless the public contract promises coverage. +- Do not make source code worse for PHPStan. Fix real types first, then use a truthful local `@var`, then a narrow identifier-scoped ignore for magic that analysis cannot model. Never add a global ignore for this work. +- Delete superseded helpers, branches, config keys, writes, imports, comments, tests, and documentation. Do not leave compatibility aliases, temporary wording, deferred TODOs, or rejected designs. +- Tests reproduce externally meaningful old failures, use deterministic scheduling where needed, preserve external-service isolation, and release every resource they own. +- Record low-confidence concerns as rejected analysis rather than implementing them. Surface a meaningful non-defect improvement with its benefit, cost, alternatives, and parity impact, then obtain owner approval before changing code. +- Avoiding overengineering never justifies an incomplete correctness, security, isolation, cleanup, or public-contract fix. + +## Research and fixed decisions + +### Current upstream surface + +Port the final current Scout source and tests, using these originating changes only as history and completeness checks: + +| Upstream change | Final surface | +|---|---| +| #416 and #505 | `HasManyThrough` searchable and unsearchable relation macros | +| #728 | database-capability raw pagination branches | +| #759 | backed-enum filter support, adapted so Hypervel escaping is never bypassed | +| #839 | Algolia `numericFilters` array to `filters` expression | +| #852 | model-selected, container-resolved Scout Builder | +| #969 | two/three-argument comparisons and structured where entries | +| #984 | one force-delete removal | +| #1003 | queue-import ordering | +| #1005 | Algolia escaping, boolean/inequality rendering, and filter grouping | + +Later current source remains authoritative. Do not port empty constructors, `Scout::VERSION`, Algolia 3, or the unused `DatabaseEngine::simplePaginate()` convenience method. + +### Worker and operation ownership + +- `EngineManager`, engines, SDK clients, and reusable HTTP transports remain worker-lifetime services. +- Each search creates a fresh mutable `Builder` through parameterized container resolution; it is never cached. +- Import progress and the concurrent import runner remain coroutine-operation state and are removed in `finally`. +- Observer suppression remains coroutine-local and nesting restores the prior enabled/disabled behavior. +- Typesense collection wrappers are operation-local handles detached from the SDK's unbounded name registry; the client and transport stay shared. +- Existing `AfterEachTestSubscriber` owners remain sufficient. Add no Scout static reset or second cleanup registry. + +### Implementation gates + +The implementation decisions are owner-approved: + +| Gate | Status, decision, and cost | +|---|---| +| `scout-08`, `scout-09` | Use `Container::makeWith()` for the model-selected Builder and four generic paginator paths. It honors Laravel substitutions and stays fresh through Hypervel's parameterized-resolution path. Cost is one cached build-recipe lookup and override push/pop per search or generic pagination, beside required DB/network work. | +| `scout-18` | Consolidate on top-level `scout.after_commit` / `SCOUT_AFTER_COMMIT`; remove nested `queue.after_commit` / `SCOUT_QUEUE_AFTER_COMMIT`. Cast the shipped env value to boolean because the observer consumes it through the strict typed getter. | +| `scout-31` | Add the three methods directly required by `ModelObserver` to `SearchableInterface`. Every conforming implementation must provide them. | +| `scout-32` | Database raw pagination honors the declared model-pagination capabilities. It returns model paginators and therefore has no engine-raw result for `withRawResults()` to transform, matching current Scout. | +| `scout-34` | Typesense rejects unknown comparison operators instead of silently rewriting them to equality. Validation remains engine-local so custom engines may support other operators. | +| `scout-37` | Detach Typesense wrappers through public ArrayAccess. This trades four short-lived wrapper objects and one local `unset()` per remote operation for bounded worker memory; it adds no network call and retains client/transport reuse. | +| `database-21` | Reject nonpositive `chunk()` / `orderedChunkById()` counts with the same `InvalidArgumentException` already used by Laravel's lazy siblings. Laravel currently returns true for zero and can issue an unbounded fetch for a negative count; names and signatures remain unchanged. | + +Apart from the approved `database-21` behavior correction, no accepted item adds a source-proven hot-path regression or changes a Laravel-facing contract. + +## Findings + +| ID | Category | Severity | Owning boundary and final result | +|---|---|---:|---| +| `scout-03` | Defect and parity | Major | `Builder` stores ordered `{field, operator, value}` constraints; Collection, Database, Meili, Typesense, and every soft-delete consumer use that shape. | +| `scout-04` | Defect and parity | Major | Algolia uses escaped `filters`, validates its six supported operators, preserves canonical numeric strings and boolean `0`/`1` for ordering, and rejects unrepresentable null or nonnumeric ordering values; Meili escapes IN values; all remote engines preserve native backed-enum scalar types at their formatter boundaries. | +| `scout-05` | Defect | Major | A force-deleted soft-delete model issues one remote removal. | +| `scout-06` | Coroutine lifecycle defect | Major | Nested `withoutSyncingToSearch()` restores the prior enabled/disabled behavior. | +| `scout-07` | Relation correctness defect | Major | `HasManyThrough` macros use the relation's through-key-aware chunk query. | +| `scout-08` | Current API parity | Major | Models may select a custom Scout Builder and container substitutions are honored without caching mutable builders. | +| `scout-09` | Container integration | Minor | Four generic pagination paths construct fresh substitutable paginators and expose truthful paginator contracts. | +| `scout-10` | Repository-convention and performance defect | Minor | Optional bulk events are constructed and dispatched only when listeners or fakes require them. | +| `scout-11` | Current queue API parity | Major | Search/removal jobs read configured tries, backoff, and max exceptions; all three jobs fail on timeout. | +| `scout-12` | Current queue API parity | Major | Opt-in unique jobs use sorted Scout keys, model class, one-hour uniqueness, and a SHA-256 JSON identity. | +| `scout-13` | Current command API and correctness | Major | Queue imports support validated ascending/descending ordering for integer and string keys and both `int`/`integer` key-type spellings. | +| `scout-14` | Error-classification defect | Major | Typesense creates only after `ObjectNotFound` and accepts only `ObjectAlreadyExists` create races. | +| `scout-15` | Error-classification defect | Major | Meilisearch creates only after 404 and accepts only `index_already_exists` races. | +| `scout-16` | Queue restoration defect | Major | Every queued removal restores a `RemoveableScoutCollection` of exact synthetic Scout identities without a DB query; all remote engines delete the serialized key. | +| `scout-17` | Package metadata defect | Major | The split manifest declares Guzzle and PSR HTTP Message directly. | +| `scout-18` | Transaction/config defect | Major | One boolean top-level after-commit setting governs observer and queued manual dispatch paths, including conventional env `1`/`0` values. | +| `scout-19` | Provenance/documentation defect | Minor | README gains upstream provenance and concise actionable differences, retaining its setext heading. | +| `scout-20` | Public docs/CLI defect | Minor | Boost docs cover accepted APIs in existing Laravel-style sections; spelling and CLI language are corrected. | +| `scout-21` | Typesense target defect | Major | Search uses explicit `within()` or `searchableAs()`; update/delete/flush use `indexableAs()`; creation uses the already-resolved target. | +| `scout-22` | Typesense remote-I/O defect | Minor | Document deletion is one DELETE and treats only `ObjectNotFound` as idempotent success. | +| `scout-23` | Typesense flush defect | Minor | Flush deletes directly, treats only `ObjectNotFound` as already flushed, and never creates first. | +| `scout-24` | Queue range defect | Major | String bounds are ordered by the database query, never by PHP comparison. | +| `scout-25` | Queue validation defect | Major | Integer bounds use `FILTER_VALIDATE_INT`; malformed, leading-zero, fractional, exponent, overflow, or reversed input fails the command. | +| `scout-26` | Command capability defect | Major | `scout:index` ignores only `NotSupportedException` from create and still applies settings. | +| `scout-27` | Settings resolution defect | Major | `scout:index` checks logical/model settings before physical prefixed-name fallback. | +| `scout-28` | String-zero defect | Minor | `scout:index --key=0` reaches the engine; only null/empty means absent. | +| `scout-29` | String-zero defect | Major | Algolia preserves `within('0')` through null-aware index selection. | +| `database-21` | Shared Database defect | Major | `chunk()` and `orderedChunkById()` reject counts below one at their common public owners. | +| `scout-30` | Typesense performance/correctness defect | Major | Update transforms first, imports optimistically, creates only after missing, and retries that import once. | +| `scout-31` | Contract defect | Major | `SearchableInterface` declares the observer-required update/delete state methods. | +| `scout-32` | Pagination defect | Major | Raw database pagination uses the engine's declared model-pagination capabilities rather than paginating its transport envelope. | +| `scout-33` | String-zero defect | Major | Collection search treats only `''` as an empty query. | +| `scout-34` | Typesense correctness divergence | Minor | Unsupported operators throw `InvalidArgumentException` at the Typesense boundary. | +| `scout-35` | Import lifecycle defect | Major | A child import failure aborts scanning, drains active children, reaches the command, and cannot contaminate later imports. | +| `scout-36` | Test typing | Minor | Scout test methods and the identified fixture parameters/return are fully typed. | +| `scout-37` | Worker-memory defect | Major | Engine-owned Typesense handles no longer accumulate per collection name or collide with SDK magic-property names. | +| `scout-38` | Attribute defect and upstream defect | Major | DatabaseEngine instantiates search attributes so named and positional columns/options behave identically. | +| `scout-39` | Pagination defect and parity | Major | All four pagination paths treat zero per-page input as the model default; length-aware pagination no longer divides by zero. | +| `database-22` | Attribute defect and upstream defect | Major | `CollectedBy` is instantiated so its named `collectionClass` is honored before worker caching. | +| `database-23` | Descending cursor type defect | Major | `forPageBeforeId()` accepts string keys so descending chunk and lazy traversal work beyond the first page. | +| `scout-40` | Integer range defect and upstream defect | Major | Descending queue-import ranges use overflow-safe remaining-distance arithmetic; the ascending branch uses the same exact formulation instead of relying on mixed numeric tie-breaking. | +| `foundation-17` | Integration-test harness defect | Major | Foundation's Meilisearch waiter reports failed awaited tasks and timeouts, while cleanup waits for its exact deletion tasks; Scout uses that single owner, a service-valid custom-key fixture, and documents Meilisearch's identifier alphabet. | +| `foundation-18` | Integration-test harness defect | Major | Foundation's Algolia cleanup waits for each exact index-deletion task before setup or teardown continues. | + +## Implementation design + +### 1. Comparison and filter contracts (`scout-03`, `04`, `29`, `33`, `34`) + +Change `Builder::$wheres` to `array` and preserve Laravel's two-argument equality form: + +```php +$this->wheres[] = [ + 'field' => $field, + 'operator' => func_num_args() === 2 ? '=' : $operator, + 'value' => func_num_args() === 2 ? $operator : $value, +]; +``` + +The public overload keeps `mixed $operator` because its two-argument form stores that parameter as the value. The selected operator is the literal `=` or the supported three-argument string operator; narrow it locally for analysis rather than casting invalid input or adding runtime machinery. + +Move the constructor soft-delete constraint and `onlyTrashed()` to this form; `withTrashed()` removes entries by literal `field`. DatabaseEngine and CollectionEngine iterate entries and use `firstWhere('field', '__soft_deleted')`; Typesense forwards the entry operator. Revalidate normal, `withTrashed()`, and `onlyTrashed()` behavior so the representation change cannot silently drop the constraint. + +Port Algolia's complete current filter path, not only operators: + +- replace `numericFilters` with the single `filters` expression; +- escape backslashes and quotes, render booleans correctly, use `NOT` for `!=`, group OR terms, join NOT-IN terms with AND, and retain the `0:1` empty-set sentinel; +- unwrap a scalar `BackedEnum` to its native backing value before branch dispatch, and unwrap IN/NOT-IN values inside `formatFilterValue()`; +- reject operators outside `=`, `!=`, `<`, `>`, `>=`, and `<=` before dispatch so an unsupported operator cannot be silently rewritten or reach raw interpolation; +- keep equality and inequality on their facet grammar (`true`/`false` for booleans), while ordering uses numeric grammar: integers, finite floats, canonical decimal strings emitted without precision-losing casts, and booleans normalized to `1`/`0`; +- reject null for every operator and reject other nonnumeric ordering values before building a malformed or injectable filter expression; +- reject non-scalar ordering values before interpolation; array equality or inequality remains a caller error surfaced by PHP, and `whereIn()` is the supported array API; +- use null-aware index selection so literal index `"0"` remains valid. + +Meilisearch unwraps a `BackedEnum` before the existing bool/null/numeric/escaped-string path rather than copying upstream's escaping-bypassing early return. Its IN/NOT-IN mapper applies the same unwrapping and `addcslashes()` escaping. Typesense accepts `BackedEnum` at `parseFilterValue()`, unwraps before recursion/boolean handling, and throws for any operator outside `=`, `!=`, `<`, `>`, `<=`, `>=`. Database/Collection leave raw enum values to Database's `castBinding()`. + +CollectionEngine checks `$builder->query === ''`; it must not treat `"0"` as empty. + +### 2. Model lifecycle, relations, builders, pagination, and events (`scout-05`–`10`, `31`, `32`, `39`, `database-21`) + +In `ModelObserver::deleted()`, skip the ordinary deleted path while a SoftDeletes model is force deleting so `forceDeleted()` remains the sole removal owner. + +Make `Searchable::withoutSyncingToSearch()` nesting-safe without adding another API. Absence and explicit `false` are indistinguishable because `ModelObserver::syncingDisabledFor()` is the only reader and casts the context value with a false default: + +```php +$wasDisabled = ! static::isSearchSyncingEnabled(); + +static::disableSearchSyncing(); + +try { + return $callback(); +} finally { + $wasDisabled ? static::disableSearchSyncing() : static::enableSearchSyncing(); +} +``` + +Do not expose the context key, add a counter or observer helper, or change `enableSyncingFor()` semantics. Only the observable nested state needs restoration. + +Register `HasManyThrough::searchable()` and `unsearchable()` alongside the existing collection macros in `registerSearchableMacros()`. The macro calls the relation's own `chunkById()` so `prepareQueryBuilder()` selects qualified far-model columns and the through key. Read both chunk defaults with the same upstream-shaped `config()` calls used by the two Eloquent Builder macros, and delete `SearchableScope::getScoutConfig()`. Keep `??` rather than upstream's `?:`: null means unspecified, while an explicit zero is invalid programmer input that must reach Database's shared chunk guard. Cover configured defaults for both Eloquent and HasManyThrough macro pairs. Registration remains boot-time, idempotent, and self-contained; add the standard `Boot-only.` warning because the touched public registrar mutates worker-global macros. + +`Searchable::search()` selects `static::$scoutBuilder ?? Builder::class` and calls `Container::makeWith()`. Keep `$scoutBuilder` undeclared: a trait property would conflict fatally with the documented model declaration. Use one narrow PHPStan ignore at the magic access and a concise docblock note. + +Use `makeWith()` for generic `Paginator` and `LengthAwarePaginator` creation in `simplePaginate()`, `paginate()`, and both raw variants. The raw variants return paginator contracts and first honor `PaginatesEloquentModels` and `PaginatesEloquentModelsUsingDatabase`. Database capability returns happen before raw callbacks because the result is already a model paginator; do not invent a synthetic raw envelope. + +In all four paths, resolve `$perPage` with `?:` rather than `??`, matching current Scout and Hypervel Eloquent. This makes zero per-page input use the model default and prevents the length-aware variants from dividing by zero. Page-number zero already self-corrects in the paginator and needs no extra branch. + +Add exactly these observer requirements to `SearchableInterface` and remove the corresponding method-not-found suppressions: + +```php +public function searchIndexShouldBeUpdated(): bool; +public function wasSearchableBeforeUpdate(): bool; +public function wasSearchableBeforeDelete(): bool; +``` + +Guard `ModelsImported` and `ModelsFlushed` construction/dispatch with `Dispatcher::hasListeners()`, as required for optional observational events by the repository convention. Resolve the dispatcher only at that boundary; progress reporting stays unconditional. Do not cache listener state or claim the required cached container resolution disappears. + +At Database's shared owner, add the same `InvalidArgumentException('The chunk size should be at least 1')` guard to both `chunk()` and `orderedChunkById()`. Zero currently returns false success; a negative limit can fetch the whole table. Scout adds no duplicate guard. + +### 3. Queue configuration, jobs, restoration, and import failure (`scout-11`–`18`, `35`) + +Port `ConfiguresJobOptions` to `src/scout/src/Traits/` and use it from MakeSearchable and RemoveFromSearch, configuring only properties the selected subclass has not set. Keep its public properties untyped with exact docblocks because `Scout::makeSearchableUsing()` and `removeFromSearchUsing()` explicitly support Laravel-style subclasses that redeclare them untyped. `tries` and `maxExceptions` are `int|null`; `backoff` is `int|list|null`; `failOnTimeout` is boolean. Publish nullable `jobs.tries`, `jobs.backoff`, and `jobs.max_exceptions` keys without inventing environment variables. MakeRangeSearchable receives only `failOnTimeout = true`. + +Port `MakeSearchableUniquely` and `RemoveFromSearchUniquely` to `src/scout/src/Jobs/`, and `UniqueByScoutKeys` to the upstream-compatible `src/scout/src/Traits/`. Use `ShouldBeUniqueUntilProcessing`, `uniqueFor = 3600`, the model class, and sorted Scout keys. Use: + +```php +return hash('sha256', json_encode($tuple, JSON_THROW_ON_ERROR)); +``` + +SHA-256 is intentional because application/user-influenced Scout keys form a uniqueness-lock trust boundary: a forged collision suppresses an index update. The work is opt-in and uses the existing Queue unique-lock owner. + +`RemoveFromSearch::restoreCollection()` consumes `ModelIdentifier::getClass()`, creates synthetic models, applies the serialized connection, sets string/int key type, and force-fills the exact stored Scout-key attribute. It performs no query or relationship restoration. Algolia, Meilisearch, and Typesense read `getScoutKeyName()` from `RemoveableScoutCollection`; ordinary collections keep `getScoutKey()`. Delete tests use `CustomScoutKeyModel` so double transformation cannot pass unnoticed. + +Move after-commit outside the queue group: + +```php +'queue' => [ + 'enabled' => env('SCOUT_QUEUE', false), + 'connection' => env('SCOUT_QUEUE_CONNECTION'), + 'queue' => env('SCOUT_QUEUE_NAME'), +], +'after_commit' => (bool) env('SCOUT_AFTER_COMMIT', false), +``` + +The observer and both queued manual collection paths read `scout.after_commit`. Automatic nonqueued model events are deferred at the observer; direct manual nonqueued collection calls retain their existing coroutine-defer semantics without a new transaction layer. + +Add `Hypervel\Scout\Console\ConcurrentImportRunner` at `src/scout/src/Console/ConcurrentImportRunner.php`. It composes `WaitConcurrent`, wraps children, and retains only the first Throwable in shared object state. Before accepting another child, rethrow the recorded failure so `chunkById()` aborts its scan. `wait()` always drains active children and rethrows the original failure. `waitForSearchableJobs()` forgets the runner in `finally`. Keep ImportCommand's existing `finally`; PHP exception chaining preserves a simultaneous body failure without a second quiet-wait API. Do not change generic `WaitConcurrent`, retain collections, aggregate failures, cancel children, retry, or add a registry. + +### 4. Queue-import and index commands (`scout-13`, `24`–`28`, `database-23`) + +Add `--order=asc` and accept only exact `asc`/`desc`. Change `QueueImportCommand::handle()` to return `int` and propagate statuses from its range dispatchers. Return `Command::FAILURE` for invalid direction or invalid/reversed integer bounds; successful/no-record paths return `Command::SUCCESS` consistently. `ImportCommand::handle()` and `FlushCommand::handle()` remain `void`: their child/engine failures propagate as exceptions and the console kernel supplies the nonzero status, while QueueImport has handled validation and no-record status paths. + +- Treat `getScoutKeyType()` values `int` and `integer` as integer ranges. +- Validate supplied/resolved integer bounds with `filter_var($value, FILTER_VALIDATE_INT)` and distinguish valid zero from `false`. +- Reject leading-zero spellings, fractions, exponents, malformed values, platform overflow, and reversed integer ranges. +- Dispatch descending integer chunks high to low. +- Compute integer range ends from the remaining distance. The previous descending expression could underflow a platform-valid lower bound to float before constructing the typed job; use the same exact calculation in both directions rather than relying on `min()` to recover the ascending integer type. +- For string keys, remove the PHP `$min > $max` check and let the bounded database query define collation and emptiness. Use `chunkById()` for ascending and `chunkByIdDesc()` for descending. A descending chunk arrives high-to-low, so dispatch the job with its last key as the low bound and first key as the high bound; report the low boundary as “down to key.” This honors ordering while keeping `MakeRangeSearchable::whereBetween()` payloads valid. +- Preserve queue and connection selection and `int|string` MakeRangeSearchable payloads. + +At Database's shared cursor owner, widen `Query\Builder::forPageBeforeId()`'s `$lastId` from `?int` to `string|int|null`, exactly matching `forPageAfterId()` and the actual values supplied by `chunkByIdDesc()` and `lazyByIdDesc()`. The current narrow type rejects string IDs on their second page; Scout adds no cast or local workaround. + +The Database regressions must execute the real method rather than mock `forPageBeforeId()`: add string-cursor SQL/binding cases to the existing `testForPageBeforeId()` and `testForPageAfterId()` pair in `DatabaseQueryBuilderTest`, and a real SQLite multi-page `chunkByIdDesc()` UUID walk to the already typed `HasUuidsTest`. The Scout UUID command regression independently proves the complete descending import and normalized range payload. + +In `IndexCommand`: + +- treat only null/empty `--key` as absent, preserving `"0"`; +- catch `NotSupportedException` only around `createIndex()` and continue settings synchronization; +- check settings by the original model/logical key, then fall back to the resolved physical index name; +- preserve all operational exceptions; +- change user-facing `Synchronised` to `Synchronized`. + +Do not extract the three identical `indexName()` methods: their algorithm is not changing, so a trait would solve no active duplication drift. + +### 5. Typesense lifecycle and bounded memory (`scout-14`, `21`–`23`, `30`, `34`, `37`) + +Replace `getOrCreateCollectionFromModel()`'s conflated target/create boolean with explicit operations: + +- resolve search to `within()` or `searchableAs()`; +- resolve update, delete, and flush to `indexableAs()`; +- replace any schema `name` with the already-resolved operation target; the schema owns fields and options, while `searchableAs()`, `indexableAs()`, or `within()` owns the collection name; +- create only for update recovery and missing-search recovery; +- catch only `ObjectNotFound` on retrieve/import/delete and only `ObjectAlreadyExists` on the create race. + +Update transforms and filters documents before touching the service. If none remain, return. Otherwise import through a detached handle; on `ObjectNotFound`, create the exact target schema, accept the one create race, and retry the same single-request import once. No generic retry or metadata preflight. + +Delete documents directly and catch only `ObjectNotFound`. Flush deletes the target collection directly and catches only `ObjectNotFound`; it never creates. Remove dead `setExists(true)` writes. + +Centralize every engine-owned collection handle through public ArrayAccess: + +```php +$collections = $this->typesense->getCollections(); +$collection = $collections[$name]; +unset($collections[$name]); + +return $collection; +``` + +Use the helper for search, update, delete, flush, and explicit delete-index paths. It prevents worker-lifetime growth across tenant/dynamic names and avoids `__get` property-name collisions. Direct application calls through `getTypesenseClient()` retain native SDK behavior. + +### 6. Meilisearch absence classification (`scout-15`) + +`getOrCreateIndex()` and create-index handling interpret only HTTP 404 as absence. Accept only the SDK error code `index_already_exists` from the create race and propagate authentication, authorization, validation, transport, server, and other conflict failures. Keep Hypervel's bounded HTTP retry policy unchanged; do not add a second retry/lock/error wrapper. + +### 7. Named reflection attributes (`scout-38`, `database-22`) + +Stop indexing `ReflectionAttribute::getArguments()` by position. Existing attribute constructors own normalization: + +```php +$attribute = $reflectionAttribute->newInstance(); +$columns = array_merge($columns, $attribute->columns); +$options = array_merge($options, $attribute->options); +``` + +DatabaseEngine does this for SearchUsingPrefix and SearchUsingFullText, including named columns/options, then keeps its existing per-model caches. Remove redundant `Arr::wrap()` imports/uses only if no other call remains. + +`HasCollection::resolveCollectionFromAttribute()` instantiates the attribute and locally narrows its `collectionClass` to the trait's `class-string` contract before returning it, retaining parent traversal and the existing worker cache. Cover both positional and named attributes. A malformed no-argument attribute now raises `ArgumentCountError` rather than silently falling through and caching the default; that is the truthful failure for a required constructor parameter. `HasEvents` and `HasGlobalScopes` flatten whole argument arrays and remain unchanged; the repository positional-index sweep is otherwise clean. + +### 8. Metadata, docs, and test typing (`scout-17`, `19`, `20`, `36`) + +Add `guzzlehttp/guzzle` `^7.15.1` and `psr/http-message` `^2.0` to `src/scout/composer.json`; the root already declares them. Add `tests/Scout/PackageMetadataTest.php` following the existing Reverb manifest test. It checks all direct runtime dependencies and pins these two exact constraints. Algolia, Meilisearch, and Typesense remain optional suggestions; Symfony Console is already declared. + +Keep the README's house-style setext heading. Add `Ported from: https://github.com/laravel/scout` and a concise `Differences From Laravel` section covering only actionable differences: Algolia 4 only, structured queue selection, coroutine-deferred nonqueue mode, command concurrency, Meilisearch retries/tenant tokens, and prefix-gated delete-all behavior. + +Update `src/boost/docs/scout.md` in the existing natural sections with short Laravel-style examples for comparisons, custom Builder selection, job options/unique job selection in the existing job section, and queue-import ordering. State concisely that Scout derives a Typesense collection name from the model index and ignores a schema `name` key. Correct `comparsion`; avoid internal runner, context, SDK-wrapper, or lifecycle detail. + +Add `: void` to the identified 218 public test methods in `CollectionEngineTest`, `CoroutineSafetyTest`, `SearchableModelTest`, `BuilderTest`, `EngineManagerTest`, `AlgoliaEngineTest`, `MeilisearchEngineTest`, `MeilisearchRetryMiddlewareTest`, `MeilisearchRetryPolicyTest`, `NullEngineTest`, and `ScoutServiceProviderTest`. Type the seven promoted `$engine` parameters in `RemoveFromSearchTest` and `MakeSearchableUsingTest`, plus the anonymous Typesense constructor parameter and `__get` parameter/return in `TypesenseEngineTest`. Do not change data providers or helpers whose returned values are consumed. + +The two existing `DatabaseQueryBuilderTest` methods retain their current signatures; typing only those methods would leave that legacy file inconsistent. The new `HasUuidsTest` regression uses `: void`, matching its class and the rule for new tests. + +### 9. External search integration task truthfulness (`foundation-17`, `foundation-18`) + +Use Foundation's `InteractsWithMeilisearch::waitForMeilisearchTasks()` as the single wait owner. Preserve its current selection of all pending tasks, inspect each terminal result returned by `waitForTask()`, throw its error when the status is not `succeeded`, and let timeout or transport exceptions propagate. Meilisearch cleanup waits for the exact `taskUid` values returned by its deletions; Algolia cleanup waits for each exact index name and `taskID`. Setup failures propagate, while teardown remains exception-safe. Delete the duplicate Support implementation; Scout keeps its longer timeout through parent delegation. + +The shared custom-key fixture uses a Meilisearch-valid hyphenated key while remaining distinct from the database key. Keep the positive remote precondition before queued removal. Add deterministic harness coverage for successful, failed, and timed-out waits rather than racing the real service, and state the Meilisearch identifier alphabet once in the custom-key guide. Do not poll in production, change `Engine::update(): void`, scan already-terminal global failures, filter by test prefix, or add a task watermark. + +## Test plan + +### Focused unit and feature regressions + +- Builder two/three-argument constraints, duplicate-field comparisons, exact soft-delete entry shape, and normal/with/only trashed behavior in Builder, Database, and Collection. +- Exact Algolia filter strings for quoted/backslashed values, facet and ordered booleans, canonical numeric strings, inequality, empty IN, grouping, string/int backed enums, and `within('0')`; unsupported operators, null, nonnumeric ordering strings, and non-finite floats fail locally. +- Exact Meilisearch escaping and backed enums in scalar, IN, and NOT-IN forms; Typesense enum and unsupported-operator behavior. +- One force-delete removal; nested normal/exceptional suppression and sibling-coroutine isolation. +- HasManyThrough search/removal with colliding far/through `id` or timestamp columns, custom chunks, and far-model Scout keys. +- Custom/default Builder resolution, all four paginator substitution paths, and zero per-page fallback; Database raw model pagination and raw-callback consequence. +- No-listener, real-listener, and EventFake bulk event behavior with independent progress. +- Configured/absent/subclass-overridden job options; timeout properties; unique ordering/class/key identity and JSON failure. +- RemoveFromSearch serialization round trips for persisted, deleted, custom-key, morph-alias, and nondefault-connection models; no DB query; exact Algolia/Meili/Typesense delete keys, with positive remote preconditions before queued custom-key removal. +- Queue-import asc/desc for integer and string keys, multiple descending string chunks, normalized string job bounds, `int`/`integer`, string collation, zero/signs, malformed/leading-zero/fraction/exponent/overflow/reversed bounds, queue/connection pass-through, and exit status. +- Strict platform-extreme integer range assertions for both directions; descending reproduces the prior underflow and ascending protects the symmetric exact formulation. +- Index unsupported-create/settings continuation, logical and physical settings lookup, primary key `"0"`, and operational error propagation. +- Typesense exact targets/schemas, including stale explicit schema names and prefixed indexes, one-request delete, no-create flush, transform-before-I/O, optimistic import, one missing-create retry, create race, error propagation, detached-handle reuse, name collision, and bounded registry size. +- Meilisearch 404/create race versus every non-absence failure. +- Import child failure preserves the original Throwable, returns failure without success output, stops further scanning, drains active children, clears context, and permits a later successful import. +- Named SearchUsingPrefix/SearchUsingFullText columns/options and cache reuse; named CollectedBy inheritance/override; malformed attributes fail fast. +- Database `chunk()` and `orderedChunkById()` reject zero/negative counts; `forPageBeforeId()` accepts a string cursor; Scout macro and command consumers inherit the corrected contracts without local checks. +- Split metadata, README provenance/differences, config shape/env removal, contract completeness, command text, and test typing. +- Meilisearch waits complete normally on success, surface an awaited task failure with its service error, propagate timeouts, and await exact cleanup tasks; Algolia awaits exact index-deletion tasks. The real custom-key removal first proves that its valid transformed key was indexed. + +### External-service integration + +Use the existing isolation traits and workflow groups: + +- Algolia: comparison/string filters, escaping, backed enums, soft-delete modes, and string-zero index where safe; +- Meilisearch: comparison/enum/escaping, absence classification, custom-key removal, soft deletes, and settings; +- Typesense: target separation, comparison/enum/operator behavior, empty/update recovery, delete/flush request semantics, detached-handle usability, custom-key removal, and soft deletes. + +Tests requiring unconfigured Algolia remain trait-skipped; configured but broken services must fail. Do not add a service, environment variable, or workflow unless a real regression cannot run under the existing Scout workflows. + +### Validation order + +1. Run each changed/new test file immediately after its source slice. +2. Run `tests/Scout` and the changed Database test files. +3. Run configured Meilisearch and Typesense integration groups; run Algolia when credentials are available. +4. Run `./vendor/bin/phpstan` and `./vendor/bin/php-cs-fixer fix` during focused work only when useful; the authoritative final gate is `composer fix`. +5. Run `composer fix` once the complete implementation is ready. It owns formatting, both PHPStan configs, the full parallel suite, and both Testbench suites. +6. Run `git diff --check`, validate the split manifest, verify zero stale config/env/helper/import references, and inspect skipped integration tests normally. + +## Fresh self-review + +After green gates, ignore the prior discussion and trace the full diff through: + +- every Builder where consumer and soft-delete mode; +- every custom Builder/paginator/contract implementer and named-argument surface; +- observer, manual collection, queue, transaction, serialization, and import-failure paths; +- every Typesense/Meili/Algolia target, request, error class, filter value, and retained SDK object; +- every Database chunk and attribute consumer; +- API/config/docs/facade/static-analysis metadata; +- worker/coroutine lifetime, allocations, I/O counts, retained memory, and test teardown; +- stale code, comments, imports, settings, env vars, and rejected mechanisms. + +If an unexpected defect or design contradiction appears, stop that edit, complete the caller/callee and same-family investigation, obtain focused second-opinion consensus, replace superseded plan wording, then implement the settled result. + +## Expected result + +Normal searches gain only the approved fresh container construction and small local filter-shape checks. Queued job creation gains three config reads. Typesense remote operations gain four temporary wrappers plus one array removal while eliminating unbounded per-name retention. In return, no-listener imports avoid event construction and dispatch traversal; Typesense removes per-delete, per-flush, and per-update network calls; empty transformed batches do no I/O. No lock, additional serialization layer, polling, logging, broad retry, per-request client, or worker registry is introduced. + +Laravel public names and signatures are restored or preserved. Nonpositive Database chunk sizes become an owner-approved behavior correction: they throw consistently with Laravel's lazy chunk APIs instead of returning success or issuing an unbounded fetch. Other intentional differences are limited to the approved Hypervel configuration structure, coroutine/nonqueue execution, bounded command concurrency, Algolia 4-only support, stronger escaping/error truthfulness, Swoole-safe clients/retries, exact delete-all safety, and Typesense operator behavior. No compatibility shim or obscure-API workaround remains. + +Foundation's test-only Meilisearch waiter reports the asynchronous task failures and timeouts it observes, and Meilisearch and Algolia cleanup own the deletion tasks they enqueue. This adds no production request, allocation, retry, or API change. + +## Completion criteria + +- all accepted Scout and Database findings are implemented at their lowest owners; +- current upstream source, tests, metadata, and documentation surfaces in scope are accounted for; +- Hypervel-specific queue, coroutine, client reuse, retries, tenant-token, range-key, and index-target enhancements remain intact; +- no accepted defect, stale config/env key, dead write/helper, TODO, workaround, or superseded documentation remains; +- focused, integration, static-analysis, formatting, full-suite, and Testbench gates are green; +- fresh self-review and independent code review are signed off; +- the companion ledger, routing index, dependency index, and completed Database entry record the final implementation and revalidation. + +The final bookkeeping pass changes all three Database dependency rows from pending to complete: `database-21` and `database-23` revalidate Database and Scout, while `database-22` revalidates Database alone. diff --git a/src/boost/docs/scout.md b/src/boost/docs/scout.md index 95ebef4e9..036388530 100644 --- a/src/boost/docs/scout.md +++ b/src/boost/docs/scout.md @@ -6,6 +6,7 @@ - [Driver Prerequisites](#driver-prerequisites) - [Configuration](#configuration) - [Configuring Searchable Data](#configuring-searchable-data) + - [Customizing the Scout Builder](#customizing-the-scout-builder) - [Database, Collection, and Null Engines](#database-and-collection-engines) - [Database Engine](#database-engine) - [Collection Engine](#collection-engine) @@ -91,20 +92,32 @@ Of course, if you customize the connection and queue that Scout jobs utilize, yo php artisan queue:work redis --queue=scout ``` -Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, `SCOUT_QUEUE_NAME`, and `SCOUT_QUEUE_AFTER_COMMIT` environment variables. +Each queue option may also be set via the `SCOUT_QUEUE`, `SCOUT_QUEUE_CONNECTION`, and `SCOUT_QUEUE_NAME` environment variables. #### Transaction-Safe Dispatch If your indexing happens inside a database transaction, set `after_commit` so the queued job is only dispatched once the transaction commits: ```php -'queue' => [ - 'enabled' => true, - 'after_commit' => true, -], +'after_commit' => true, ``` -This stops the queue worker from picking up an indexing job for a record that no longer exists because the transaction was rolled back. +This option may also be set using the `SCOUT_AFTER_COMMIT` environment variable. It stops the queue worker from picking up an indexing job for a record that no longer exists because the transaction was rolled back. + +#### Unique Jobs + +In write-heavy applications, you may prevent Scout from queueing duplicate jobs for the same model records by registering the unique job classes in a service provider: + +```php +use Hypervel\Scout\Jobs\MakeSearchableUniquely; +use Hypervel\Scout\Jobs\RemoveFromSearchUniquely; +use Hypervel\Scout\Scout; + +Scout::makeSearchableUsing(MakeSearchableUniquely::class); +Scout::removeFromSearchUsing(RemoveFromSearchUniquely::class); +``` + +These jobs use [unique job locks](/docs/{{version}}/queues#unique-jobs) while a matching indexing operation is waiting to be processed. #### When to Use Each Mode @@ -259,6 +272,19 @@ class User extends Model implements SearchableInterface } ``` + +### Customizing the Scout Builder + +To use a custom Scout query builder for a model, define the `$scoutBuilder` property on the model: + +```php +use App\Scout\CustomScoutBuilder; + +protected static string $scoutBuilder = CustomScoutBuilder::class; +``` + +The custom class should extend `Hypervel\Scout\Builder` and will be resolved through the service container for each search. + ## Database, Collection, and Null Engines @@ -421,6 +447,9 @@ class User extends Model implements SearchableInterface > [!NOTE] > The `getScoutKey` and `getScoutKeyName` methods have no effect when using the database engine, which always uses the model's primary key. +> [!WARNING] +> Meilisearch document identifiers may contain only letters, numbers, hyphens, and underscores, so custom Scout keys must follow that format when using Meilisearch. + ### Algolia @@ -583,7 +612,7 @@ public function toSearchableArray(): array } ``` -You may define your Typesense collection schema in your application's `config/scout.php` file under `typesense.model-settings`, or directly on the model by defining a `typesenseCollectionSchema` method. A collection schema describes the data types of each field that is searchable via Typesense. For more information on all available schema options, please consult the [Typesense documentation](https://typesense.org/docs/latest/api/collections.html#schema-parameters). +You may define your Typesense collection schema in your application's `config/scout.php` file under `typesense.model-settings`, or directly on the model by defining a `typesenseCollectionSchema` method. A collection schema describes the data types of each field that is searchable via Typesense. Scout derives the collection name from the model's index name, so a `name` key in the schema is ignored. For more information on all available schema options, please consult the [Typesense documentation](https://typesense.org/docs/latest/api/collections.html#schema-parameters). If you need to change your Typesense collection's schema after it has been defined, you may either run `scout:flush` and `scout:import`, which will delete all existing indexed data and recreate the schema. Or, you may use Typesense's API to modify the collection's schema without removing any indexed data. @@ -650,6 +679,12 @@ You may optionally control the chunk size, the ID range, and the destination que php artisan scout:queue-import "App\Models\Post" --chunk=500 --min=1000 --max=50000 --queue=imports ``` +Imports run in ascending key order by default. To process the range from the highest key to the lowest, pass `--order=desc`: + +```shell +php artisan scout:queue-import "App\Models\Post" --order=desc +``` + The `--min` and `--max` options are useful for resuming a partial import, or for running several imports in parallel against different ranges. There is no `--fresh` option — to rebuild the index from scratch, run `scout:flush` first. The `flush` command may be used to remove all of a model's records from your search indexes: @@ -892,6 +927,18 @@ public function boot(): void Custom job classes should extend the corresponding default job and override only the methods you need to change. These overrides only affect queue-mode indexing — in the default mode, indexing runs inline via `Coroutine::defer` and does not pass through a job class. +You may configure the attempts, retry delay, and maximum unhandled exceptions for the default jobs in `config/scout.php`: + +```php +'jobs' => [ + 'tries' => 3, + 'backoff' => [1, 5, 10], + 'max_exceptions' => 2, +], +``` + +Values defined by a custom job class take precedence over these settings. + ## Searching @@ -942,7 +989,7 @@ use App\Models\Order; $orders = Order::search('Star Trek')->where('user_id', 1)->get(); ``` -You may also use the `=`, `!=`, `<`, `>`, `>=`, `<=` comparsion operators to build more advanced queries: +You may also use the `=`, `!=`, `<`, `>`, `>=`, `<=` comparison operators to build more advanced queries: ```php Order::search('Star Trek') diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index c9962df7c..27d05913b 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -38,6 +38,10 @@ trait BuildsQueries */ public function chunk(int $count, callable $callback): bool { + if ($count < 1) { + throw new InvalidArgumentException('The chunk size should be at least 1'); + } + $this->enforceOrderBy(); $skip = $this->getOffset(); @@ -143,6 +147,10 @@ public function chunkByIdDesc(int $count, callable $callback, ?string $column = */ public function orderedChunkById(int $count, callable $callback, ?string $column = null, ?string $alias = null, SortDirection|bool $descending = false): bool { + if ($count < 1) { + throw new InvalidArgumentException('The chunk size should be at least 1'); + } + $column ??= $this->defaultKeyName(); $alias ??= $column; $lastId = null; diff --git a/src/database/src/Eloquent/HasCollection.php b/src/database/src/Eloquent/HasCollection.php index 5ea950156..10ade2de7 100644 --- a/src/database/src/Eloquent/HasCollection.php +++ b/src/database/src/Eloquent/HasCollection.php @@ -30,13 +30,13 @@ public function newCollection(array $models = []): Collection // @phpstan-ignore assign.propertyType (generic type narrowing loss with static property) static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? static::$collectionClass); + /** @var TCollection $collection */ $collection = new static::$resolvedCollectionClasses[static::class]($models); if (Model::isAutomaticallyEagerLoadingRelationships()) { $collection->withRelationshipAutoloading(); } - // @phpstan-ignore return.type (dynamic class instantiation from static property loses generic type) return $collection; } @@ -52,8 +52,11 @@ public function resolveCollectionFromAttribute(): ?string do { $attributes = $reflection->getAttributes(CollectedBy::class); - if (isset($attributes[0], $attributes[0]->getArguments()[0])) { - return $attributes[0]->getArguments()[0]; + if (isset($attributes[0])) { + /** @var class-string $collectionClass */ + $collectionClass = $attributes[0]->newInstance()->collectionClass; + + return $collectionClass; } } while ($reflection = $reflection->getParentClass()); diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 8a9e72035..1c89784c0 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -2604,7 +2604,7 @@ public function forPage(int $page, int $perPage = 15): static /** * Constrain the query to the previous "page" of results before a given ID. */ - public function forPageBeforeId(int $perPage = 15, ?int $lastId = 0, string $column = 'id'): static + public function forPageBeforeId(int $perPage = 15, string|int|null $lastId = 0, string $column = 'id'): static { $this->orders = $this->removeExistingOrdersFor($column); diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php index 21b110752..5ad866a8f 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php @@ -81,7 +81,11 @@ protected function tearDownInteractsWithAlgolia(): void { try { if ($this->algolia !== null) { - $this->cleanupAlgoliaIndices(); + try { + $this->cleanupAlgoliaIndices(); + } catch (Throwable) { + // Ignore cleanup errors + } } } finally { $this->algolia = null; @@ -121,18 +125,21 @@ protected function cleanupAlgoliaIndices(): void return; } - try { - $indices = $this->algolia->listIndices(); + $tasks = []; + $indices = $this->algolia->listIndices(); - foreach ($indices['items'] ?? [] as $index) { - $name = $index['name'] ?? null; + foreach ($indices['items'] ?? [] as $index) { + $name = $index['name'] ?? null; - if (is_string($name) && str_starts_with($name, $this->algoliaTestPrefix)) { - $this->algolia->deleteIndex($name); - } + if (is_string($name) && str_starts_with($name, $this->algoliaTestPrefix)) { + /** @var array{taskID: int} $task */ + $task = $this->algolia->deleteIndex($name); + $tasks[] = ['indexName' => $name, 'taskID' => $task['taskID']]; } - } catch (Throwable) { - // Ignore errors during cleanup + } + + foreach ($tasks as $task) { + $this->algolia->waitForTask($task['indexName'], $task['taskID']); } } } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php index a2314d86f..fd8a3a47c 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Testing\Concerns; use Meilisearch\Client as MeilisearchClient; +use RuntimeException; use Throwable; /** @@ -113,16 +114,21 @@ protected function cleanupMeilisearchIndexes(): void return; } - try { - $indexes = $this->meilisearch->getIndexes(); + $taskUids = []; + $indexes = $this->meilisearch->getIndexes(); + + foreach ($indexes->getResults() as $index) { + if (str_starts_with($index->getUid(), $this->meilisearchTestPrefix)) { + $task = $this->meilisearch->deleteIndex($index->getUid()); - foreach ($indexes->getResults() as $index) { - if (str_starts_with($index->getUid(), $this->meilisearchTestPrefix)) { - $this->meilisearch->deleteIndex($index->getUid()); - } + /** @var int $taskUid */ + $taskUid = $task['taskUid']; + $taskUids[] = $taskUid; } - } catch (Throwable) { - // Ignore errors during cleanup + } + + foreach ($taskUids as $taskUid) { + $this->waitForMeilisearchTask($taskUid); } } @@ -135,15 +141,29 @@ protected function waitForMeilisearchTasks(int $timeoutMs = 5000): void return; } - try { - $tasks = $this->meilisearch->getTasks(); - foreach ($tasks->getResults() as $task) { - if (in_array($task['status'], ['enqueued', 'processing'], true)) { - $this->meilisearch->waitForTask($task['uid'], $timeoutMs); - } + $tasks = $this->meilisearch->getTasks(); + + foreach ($tasks->getResults() as $task) { + if (in_array($task['status'], ['enqueued', 'processing'], true)) { + /** @var int $taskUid */ + $taskUid = $task['uid']; + $this->waitForMeilisearchTask($taskUid, $timeoutMs); } - } catch (Throwable) { - // Ignore timeout errors + } + } + + /** + * Wait for a Meilisearch task to complete successfully. + */ + protected function waitForMeilisearchTask(int $taskUid, int $timeoutMs = 5000): void + { + $task = $this->meilisearch->waitForTask($taskUid, $timeoutMs); + + if ($task['status'] !== 'succeeded') { + throw new RuntimeException( + $task['error']['message'] + ?? "Meilisearch task [{$task['uid']}] ended with status [{$task['status']}]." + ); } } } diff --git a/src/scout/README.md b/src/scout/README.md index 951b44584..754767936 100644 --- a/src/scout/README.md +++ b/src/scout/README.md @@ -1,4 +1,15 @@ Scout for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/scout) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/scout) + +Ported from: https://github.com/laravel/scout + +Differences From Laravel +--- + +- Algolia 4 is the only supported Algolia client. +- Queue mode supports dedicated connection and queue selection; nonqueued indexing runs after the response in a coroutine. +- Command imports use bounded coroutine concurrency. +- Meilisearch requests use bounded retries and support tenant tokens. +- Destructive index deletion requires the configured Scout prefix. diff --git a/src/scout/composer.json b/src/scout/composer.json index 4a1cbc03d..ffe83d897 100644 --- a/src/scout/composer.json +++ b/src/scout/composer.json @@ -35,6 +35,7 @@ }, "require": { "php": "^8.4", + "guzzlehttp/guzzle": "^7.15.1", "hypervel/collections": "^0.4", "hypervel/conditionable": "^0.4", "hypervel/config": "^0.4", @@ -49,6 +50,7 @@ "hypervel/pagination": "^0.4", "hypervel/queue": "^0.4", "hypervel/support": "^0.4", + "psr/http-message": "^2.0", "symfony/console": "^8.1" }, "suggest": { diff --git a/src/scout/config/scout.php b/src/scout/config/scout.php index 7e9f54530..da7a59ba5 100644 --- a/src/scout/config/scout.php +++ b/src/scout/config/scout.php @@ -45,17 +45,41 @@ | response is emitted). Set 'enabled' to true to use | the queue system instead for durability and retries. | - | The 'after_commit' option ensures that queued indexing jobs are only - | dispatched after all database transactions have committed, preventing - | indexing of data that might be rolled back. - | */ 'queue' => [ 'enabled' => env('SCOUT_QUEUE', false), 'connection' => env('SCOUT_QUEUE_CONNECTION'), 'queue' => env('SCOUT_QUEUE_NAME'), - 'after_commit' => env('SCOUT_QUEUE_AFTER_COMMIT', false), + ], + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This option determines if your data will only be synced with your search + | indexes after every open database transaction has been committed. This + | prevents discarded data from being synchronized with your indexes. + | + */ + + 'after_commit' => (bool) env('SCOUT_AFTER_COMMIT', false), + + /* + |-------------------------------------------------------------------------- + | Job Configuration + |-------------------------------------------------------------------------- + | + | These options control how queued Scout jobs are retried and failed. Null + | values allow the queue worker or a custom Scout job to decide. + | + */ + + 'jobs' => [ + 'tries' => null, + 'backoff' => null, + 'max_exceptions' => null, ], /* diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php index 62ad02f83..64376282c 100644 --- a/src/scout/src/Builder.php +++ b/src/scout/src/Builder.php @@ -5,6 +5,7 @@ namespace Hypervel\Scout; use Closure; +use Hypervel\Container\Container; use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Contracts\Support\Arrayable; @@ -69,7 +70,7 @@ class Builder /** * The "where" constraints added to the query. * - * @var array + * @var array */ public array $wheres = []; @@ -122,7 +123,11 @@ public function __construct( $this->callback = $callback; if ($softDelete) { - $this->wheres['__soft_deleted'] = 0; + $this->wheres[] = [ + 'field' => '__soft_deleted', + 'operator' => '=', + 'value' => 0, + ]; } } @@ -143,9 +148,16 @@ public function within(string $index): static * * @return $this */ - public function where(string $field, mixed $value): static + public function where(string $field, mixed $operator, mixed $value = null): static { - $this->wheres[$field] = $value; + /** @var string $selectedOperator */ + $selectedOperator = func_num_args() === 2 ? '=' : $operator; + + $this->wheres[] = [ + 'field' => $field, + 'operator' => $selectedOperator, + 'value' => func_num_args() === 2 ? $operator : $value, + ]; return $this; } @@ -191,7 +203,10 @@ public function whereNotIn(string $field, array|Arrayable $values): static */ public function withTrashed(): static { - unset($this->wheres['__soft_deleted']); + $this->wheres = collect($this->wheres) + ->where('field', '!==', '__soft_deleted') + ->values() + ->all(); return $this; } @@ -204,7 +219,11 @@ public function withTrashed(): static public function onlyTrashed(): static { return tap($this->withTrashed(), function () { - $this->wheres['__soft_deleted'] = 1; + $this->wheres[] = [ + 'field' => '__soft_deleted', + 'operator' => '=', + 'value' => 1, + ]; }); } @@ -363,8 +382,8 @@ public function simplePaginate( ): PaginatorContract { $engine = $this->engine(); - $page = $page ?? Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?? $this->model->getPerPage(); + $page = $page ?: Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?: $this->model->getPerPage(); if ($engine instanceof PaginatesEloquentModels) { return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); @@ -383,10 +402,15 @@ public function simplePaginate( )->all(); $results = $this->model->newCollection($mappedModels); - return (new Paginator($results, $perPage, $page, [/* @phpstan-ignore-line */ - 'path' => Paginator::resolveCurrentPath(), - 'pageName' => $pageName, - ]))->hasMorePagesWhen( + return Container::getInstance()->makeWith(Paginator::class, [ + 'items' => $results, + 'perPage' => $perPage, + 'currentPage' => $page, + 'options' => [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ], + ])->hasMorePagesWhen( ($perPage * $page) < $engine->getTotalCount($rawResults) )->appends('query', $this->query); } @@ -401,8 +425,8 @@ public function paginate( ): LengthAwarePaginatorContract { $engine = $this->engine(); - $page = $page ?? Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?? $this->model->getPerPage(); + $page = $page ?: Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?: $this->model->getPerPage(); if ($engine instanceof PaginatesEloquentModels) { return $engine->paginate($this, $perPage, $page)->appends('query', $this->query); @@ -421,16 +445,16 @@ public function paginate( )->all(); $results = $this->model->newCollection($mappedModels); - return (new LengthAwarePaginator( - $results, /* @phpstan-ignore-line */ - $this->getTotalCount($rawResults), - $perPage, - $page, - [ + return Container::getInstance()->makeWith(LengthAwarePaginator::class, [ + 'items' => $results, + 'total' => $this->getTotalCount($rawResults), + 'perPage' => $perPage, + 'currentPage' => $page, + 'options' => [ 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, - ] - ))->appends('query', $this->query); + ], + ])->appends('query', $this->query); } /** @@ -440,26 +464,34 @@ public function paginateRaw( ?int $perPage = null, string $pageName = 'page', ?int $page = null - ): LengthAwarePaginator { + ): LengthAwarePaginatorContract { $engine = $this->engine(); - $page = $page ?? Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?? $this->model->getPerPage(); + $page = $page ?: Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?: $this->model->getPerPage(); + + if ($engine instanceof PaginatesEloquentModels) { + return $engine->paginate($this, $perPage, $page)->appends('query', $this->query); + } + + if ($engine instanceof PaginatesEloquentModelsUsingDatabase) { + return $engine->paginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); + } $results = $this->applyAfterRawSearchCallback( $engine->paginate($this, $perPage, $page) ); - return (new LengthAwarePaginator( - $results, - $this->getTotalCount($results), - $perPage, - $page, - [ + return Container::getInstance()->makeWith(LengthAwarePaginator::class, [ + 'items' => $results, + 'total' => $this->getTotalCount($results), + 'perPage' => $perPage, + 'currentPage' => $page, + 'options' => [ 'path' => Paginator::resolveCurrentPath(), 'pageName' => $pageName, - ] - ))->appends('query', $this->query); + ], + ])->appends('query', $this->query); } /** @@ -469,20 +501,33 @@ public function simplePaginateRaw( ?int $perPage = null, string $pageName = 'page', ?int $page = null - ): Paginator { + ): PaginatorContract { $engine = $this->engine(); - $page = $page ?? Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?? $this->model->getPerPage(); + $page = $page ?: Paginator::resolveCurrentPage($pageName); + $perPage = $perPage ?: $this->model->getPerPage(); + + if ($engine instanceof PaginatesEloquentModels) { + return $engine->simplePaginate($this, $perPage, $page)->appends('query', $this->query); + } + + if ($engine instanceof PaginatesEloquentModelsUsingDatabase) { + return $engine->simplePaginateUsingDatabase($this, $perPage, $pageName, $page)->appends('query', $this->query); + } $results = $this->applyAfterRawSearchCallback( $engine->paginate($this, $perPage, $page) ); - return (new Paginator($results, $perPage, $page, [ - 'path' => Paginator::resolveCurrentPath(), - 'pageName' => $pageName, - ]))->hasMorePagesWhen( + return Container::getInstance()->makeWith(Paginator::class, [ + 'items' => $results, + 'perPage' => $perPage, + 'currentPage' => $page, + 'options' => [ + 'path' => Paginator::resolveCurrentPath(), + 'pageName' => $pageName, + ], + ])->hasMorePagesWhen( ($perPage * $page) < $engine->getTotalCount($results) )->appends('query', $this->query); } diff --git a/src/scout/src/Console/ConcurrentImportRunner.php b/src/scout/src/Console/ConcurrentImportRunner.php new file mode 100644 index 000000000..38c7409b9 --- /dev/null +++ b/src/scout/src/Console/ConcurrentImportRunner.php @@ -0,0 +1,62 @@ +concurrent = new WaitConcurrent($limit); + } + + /** + * Run an import operation within the concurrency limit. + */ + public function create(callable $operation): void + { + $this->throwIfFailed(); + + $this->concurrent->create(function () use ($operation): void { + if ($this->failure !== null) { + return; + } + + try { + $operation(); + } catch (Throwable $exception) { + $this->failure ??= $exception; + } + }); + + $this->throwIfFailed(); + } + + /** + * Wait for every active import operation and rethrow the first failure. + */ + public function wait(): void + { + $this->concurrent->wait(); + + $this->throwIfFailed(); + } + + /** + * Rethrow the first import failure. + */ + protected function throwIfFailed(): void + { + if ($this->failure !== null) { + throw $this->failure; + } + } +} diff --git a/src/scout/src/Console/IndexCommand.php b/src/scout/src/Console/IndexCommand.php index 308f0d972..4d4c750f3 100644 --- a/src/scout/src/Console/IndexCommand.php +++ b/src/scout/src/Console/IndexCommand.php @@ -10,6 +10,7 @@ use Hypervel\Scout\Contracts\UpdatesIndexSettings; use Hypervel\Scout\EngineManager; use Hypervel\Scout\Engines\Engine; +use Hypervel\Scout\Exceptions\NotSupportedException; use Hypervel\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; @@ -40,8 +41,10 @@ public function handle(EngineManager $manager, Repository $config): int $options = []; - if ($this->option('key')) { - $options = ['primaryKey' => $this->option('key')]; + $key = $this->option('key'); + + if ($key !== null && $key !== '') { + $options = ['primaryKey' => $key]; } $model = null; @@ -58,10 +61,8 @@ public function handle(EngineManager $manager, Repository $config): int if ($engine instanceof UpdatesIndexSettings) { $driver = $config->string('scout.driver'); - $class = $model !== null ? get_class($model) : null; - - $settings = $config->get("scout.{$driver}.index-settings.{$name}") - ?? ($class !== null ? $config->get("scout.{$driver}.index-settings.{$class}") : null) + $settings = $config->get("scout.{$driver}.index-settings.{$modelName}") + ?? $config->get("scout.{$driver}.index-settings.{$name}") ?? []; if ($model !== null @@ -75,7 +76,7 @@ public function handle(EngineManager $manager, Repository $config): int } } - $this->info("Synchronised index [\"{$name}\"] successfully."); + $this->info("Synchronized index [\"{$name}\"] successfully."); return self::SUCCESS; } @@ -87,7 +88,11 @@ public function handle(EngineManager $manager, Repository $config): int */ protected function createIndex(Engine $engine, string $name, array $options): void { - $engine->createIndex($name, $options); + try { + $engine->createIndex($name, $options); + } catch (NotSupportedException) { + // The engine creates indexes implicitly. + } } /** diff --git a/src/scout/src/Console/QueueImportCommand.php b/src/scout/src/Console/QueueImportCommand.php index 42ea996ea..3c6990183 100644 --- a/src/scout/src/Console/QueueImportCommand.php +++ b/src/scout/src/Console/QueueImportCommand.php @@ -28,6 +28,7 @@ class QueueImportCommand extends Command {--min= : The minimum key value to start queuing from} {--max= : The maximum key value to queue up to} {--c|chunk= : The number of records to queue in a single job (Defaults to configuration value: `scout.chunk.searchable`)} + {--order=asc : The order in which ranges should be queued (`asc` or `desc`)} {--queue= : The queue that should be used (Defaults to configuration value: `scout.queue.queue`)}'; /** @@ -40,7 +41,7 @@ class QueueImportCommand extends Command * * @throws ScoutException */ - public function handle(Repository $config): void + public function handle(Repository $config): int { $class = $this->resolveModelClass((string) $this->argument('model')); @@ -50,18 +51,23 @@ public function handle(Repository $config): void $chunk = max(1, (int) ($this->option('chunk') ?? $config->integer('scout.chunk.searchable', 500))); $queueName = $this->option('queue') ?? $model->syncWithSearchUsingQueue(); $connection = $model->syncWithSearchUsing(); + $order = (string) $this->option('order'); - if ($model->getScoutKeyType() === 'int') { - $this->dispatchIntegerRange($class, $model, $chunk, $queueName, $connection); - } else { - $this->dispatchStringRange($class, $model, $chunk, $queueName, $connection); + if (! in_array($order, ['asc', 'desc'], true)) { + $this->error('The order option must be either "asc" or "desc".'); + + return Command::FAILURE; } + + return in_array($model->getScoutKeyType(), ['int', 'integer'], true) + ? $this->dispatchIntegerRange($class, $model, $chunk, $order, $queueName, $connection) + : $this->dispatchStringRange($class, $model, $chunk, $order, $queueName, $connection); } /** * Dispatch range jobs for an integer-keyed model using min/max arithmetic. */ - protected function dispatchIntegerRange(string $class, SearchableInterface $model, int $chunk, ?string $queueName, ?string $connection): void + protected function dispatchIntegerRange(string $class, SearchableInterface $model, int $chunk, string $order, ?string $queueName, ?string $connection): int { $query = $class::makeAllSearchableQuery(); $keyName = $model->getScoutKeyName(); @@ -73,26 +79,60 @@ protected function dispatchIntegerRange(string $class, SearchableInterface $mode if ($min === null || $max === null) { $this->info("No records found for [{$class}]."); - return; + return Command::SUCCESS; + } + + $min = $this->parseIntegerBound($min); + $max = $this->parseIntegerBound($max); + + if ($min === false || $max === false) { + $this->error("The minimum and maximum keys for [{$class}] must be valid integers."); + + return Command::FAILURE; } - if ((int) $min > (int) $max) { + if ($min > $max) { $this->error("Invalid range for [{$class}]: --min ({$min}) is greater than --max ({$max})."); - return; + return Command::FAILURE; } - for ($start = (int) $min; $start <= (int) $max; $start += $chunk) { - $end = min($start + $chunk - 1, (int) $max); + // An overflowed distance exceeds every integer offset, so the selected endpoint stays representable. + $offset = $chunk - 1; - MakeRangeSearchable::dispatch($class, $start, $end) - ->onQueue($queueName) - ->onConnection($connection); + if ($order === 'asc') { + for ($start = $min; $start <= $max; $start = $end + 1) { + $end = $max - $start < $offset ? $max : $start + $offset; + + MakeRangeSearchable::dispatch($class, $start, $end) + ->onQueue($queueName) + ->onConnection($connection); + + $this->line("Queued [{$class}] models up to ID: {$end}"); + + if ($end === $max) { + break; + } + } + } else { + for ($end = $max; $end >= $min; $end = $start - 1) { + $start = $end - $min < $offset ? $min : $end - $offset; + + MakeRangeSearchable::dispatch($class, $start, $end) + ->onQueue($queueName) + ->onConnection($connection); - $this->line("Queued [{$class}] models up to ID: {$end}"); + $this->line("Queued [{$class}] models down to ID: {$start}"); + + if ($start === $min) { + break; + } + } } $this->info("All [{$class}] records have been queued for importing."); + + return Command::SUCCESS; } /** @@ -102,7 +142,7 @@ protected function dispatchIntegerRange(string $class, SearchableInterface $mode * dispatches one MakeRangeSearchable per chunk with the first/last keys * in that chunk. Workers re-query their range via whereBetween. */ - protected function dispatchStringRange(string $class, SearchableInterface $model, int $chunk, ?string $queueName, ?string $connection): void + protected function dispatchStringRange(string $class, SearchableInterface $model, int $chunk, string $order, ?string $queueName, ?string $connection): int { $query = $class::makeAllSearchableQuery(); $keyName = $model->getScoutKeyName(); @@ -110,36 +150,57 @@ protected function dispatchStringRange(string $class, SearchableInterface $model $min = $this->option('min'); $max = $this->option('max'); - if ($min !== null && $max !== null && (string) $min > (string) $max) { - $this->error("Invalid range for [{$class}]: --min ({$min}) is greater than --max ({$max})."); - - return; - } - $jobsDispatched = 0; - $query + $query = $query ->select("{$qualified} as {$keyName}") ->when($min !== null, fn ($q) => $q->where($qualified, '>=', $min)) - ->when($max !== null, fn ($q) => $q->where($qualified, '<=', $max)) - ->chunkById($chunk, function ($keys) use ($class, $keyName, $queueName, $connection, &$jobsDispatched): void { - $start = $keys->first()->{$keyName}; - $end = $keys->last()->{$keyName}; + ->when($max !== null, fn ($q) => $q->where($qualified, '<=', $max)); - MakeRangeSearchable::dispatch($class, $start, $end) - ->onQueue($queueName) - ->onConnection($connection); + $dispatch = function ($keys) use ($class, $keyName, $order, $queueName, $connection, &$jobsDispatched): void { + $start = (string) ($order === 'asc' ? $keys->first()->{$keyName} : $keys->last()->{$keyName}); + $end = (string) ($order === 'asc' ? $keys->last()->{$keyName} : $keys->first()->{$keyName}); - $this->line("Queued [{$class}] models up to key: {$end}"); - ++$jobsDispatched; - }, $qualified, $keyName); + MakeRangeSearchable::dispatch($class, $start, $end) + ->onQueue($queueName) + ->onConnection($connection); + + $this->line($order === 'asc' + ? "Queued [{$class}] models up to key: {$end}" + : "Queued [{$class}] models down to key: {$start}"); + ++$jobsDispatched; + }; + + if ($order === 'asc') { + $query->chunkById($chunk, $dispatch, $qualified, $keyName); + } else { + $query->chunkByIdDesc($chunk, $dispatch, $qualified, $keyName); + } if ($jobsDispatched === 0) { $this->info("No records found for [{$class}]."); - return; + return Command::SUCCESS; } $this->info("All [{$class}] records have been queued for importing."); + + return Command::SUCCESS; + } + + /** + * Parse an integer range boundary. + */ + protected function parseIntegerBound(mixed $value): int|false + { + if (is_string($value) && preg_match('/^[+-]?(?:0|[1-9][0-9]*)$/D', $value) !== 1) { + return false; + } + + if (! is_int($value) && ! is_string($value)) { + return false; + } + + return filter_var($value, FILTER_VALIDATE_INT); } } diff --git a/src/scout/src/Contracts/SearchableInterface.php b/src/scout/src/Contracts/SearchableInterface.php index daacc385d..dd6b3bb8f 100644 --- a/src/scout/src/Contracts/SearchableInterface.php +++ b/src/scout/src/Contracts/SearchableInterface.php @@ -92,6 +92,21 @@ public function toSearchableArray(): array; */ public function shouldBeSearchable(): bool; + /** + * Determine if the model's search index should be updated. + */ + public function searchIndexShouldBeUpdated(): bool; + + /** + * Determine if the model existed in the search index prior to an update. + */ + public function wasSearchableBeforeUpdate(): bool; + + /** + * Determine if the model existed in the search index prior to deletion. + */ + public function wasSearchableBeforeDelete(): bool; + /** * Get the value used to index the model. */ diff --git a/src/scout/src/Engines/AlgoliaEngine.php b/src/scout/src/Engines/AlgoliaEngine.php index b110eeb8a..33017c35a 100644 --- a/src/scout/src/Engines/AlgoliaEngine.php +++ b/src/scout/src/Engines/AlgoliaEngine.php @@ -6,6 +6,7 @@ use Algolia\AlgoliaSearch\Api\SearchClient as AlgoliaSearchClient; use Algolia\AlgoliaSearch\Exceptions\AlgoliaException; +use BackedEnum; use Hypervel\Context\RequestContext; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -17,6 +18,7 @@ use Hypervel\Scout\Jobs\RemoveableScoutCollection; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use InvalidArgumentException; /** * Algolia search engine implementation (v4 client). @@ -114,7 +116,7 @@ public function delete(EloquentCollection $models): void public function search(Builder $builder): mixed { return $this->performSearch($builder, array_filter([ - 'numericFilters' => $this->filters($builder), + 'filters' => $this->filters($builder), 'hitsPerPage' => $builder->limit, ])); } @@ -125,7 +127,7 @@ public function search(Builder $builder): mixed public function paginate(Builder $builder, int $perPage, int $page): mixed { return $this->performSearch($builder, [ - 'numericFilters' => $this->filters($builder), + 'filters' => $this->filters($builder), 'hitsPerPage' => $perPage, 'page' => $page - 1, ]); @@ -158,7 +160,7 @@ protected function performSearch(Builder $builder, array $options = []): mixed } return $this->algolia->searchSingleIndex( - $builder->index ?: $builder->model->searchableAs(), + $builder->index ?? $builder->model->searchableAs(), $queryParams, $requestOptions, ); @@ -202,37 +204,103 @@ protected function identifyHeaders(): array } /** - * Get the filter array for the query. - * - * @return array + * Get the filter expression for the query. */ - protected function filters(Builder $builder): array + protected function filters(Builder $builder): string { $wheres = collect($builder->wheres) - ->map(fn ($value, $key) => $key . '=' . $value) + ->map(function (array $where): string { + $field = $where['field']; + $operator = $where['operator']; + $value = $where['value']; + + if (! in_array($operator, ['=', '!=', '<', '>', '<=', '>='], true)) { + throw new InvalidArgumentException("Unsupported Algolia filter operator [{$operator}]."); + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if ($value === null) { + throw new InvalidArgumentException('Algolia filters do not support null values.'); + } + + if (is_float($value) && ! is_finite($value)) { + throw new InvalidArgumentException('Algolia filters require finite numeric values.'); + } + + if (in_array($operator, ['<', '>', '<=', '>='], true)) { + if (is_bool($value)) { + $value = $value ? '1' : '0'; + } elseif (is_int($value) || is_float($value)) { + $value = (string) $value; + } elseif (! is_string($value) + || preg_match('/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/D', $value) !== 1) { + throw new InvalidArgumentException('Algolia ordering filters require a numeric value.'); + } + + return $field . $operator . $value; + } + + if (is_string($value) || is_bool($value)) { + $value = $this->formatFilterValue($value); + + if ($operator === '!=') { + return 'NOT ' . $field . ':' . $value; + } + + $operator = ':'; + } elseif ($operator === '=') { + $operator = ':'; + $value = "'{$value}'"; + } + + return $field . $operator . $value; + }) ->values(); - $whereIns = collect($builder->whereIns)->map(function ($values, $key) { - if (empty($values)) { - return '0=1'; - } + $whereIns = collect($builder->whereIns) + ->map(function (array $values, string $key): string { + if ($values === []) { + return '0:1'; + } - return collect($values) - ->map(fn ($value) => $key . '=' . $value) - ->all(); - })->values(); + return '(' . collect($values) + ->map(fn (mixed $value): string => $key . ':' . $this->formatFilterValue($value)) + ->implode(' OR ') . ')'; + }) + ->values(); - $whereNotIns = collect($builder->whereNotIns)->flatMap(function ($values, $key) { - if (empty($values)) { - return []; - } + $whereNotIns = collect($builder->whereNotIns) + ->map(function (array $values, string $key): string { + if ($values === []) { + return ''; + } + + return collect($values) + ->map(fn (mixed $value): string => 'NOT ' . $key . ':' . $this->formatFilterValue($value)) + ->implode(' AND '); + }) + ->values(); + + return $wheres->merge($whereIns)->merge($whereNotIns)->filter()->implode(' AND '); + } - return collect($values) - ->map(fn ($value) => $key . '!=' . $value) - ->all(); - }); + /** + * Format the given value for use in an Algolia filter. + */ + protected function formatFilterValue(mixed $value): string + { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } - return $wheres->merge($whereIns)->merge($whereNotIns)->values()->all(); + return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], (string) $value) . "'"; } /** diff --git a/src/scout/src/Engines/CollectionEngine.php b/src/scout/src/Engines/CollectionEngine.php index fa9e3c232..32de4208e 100644 --- a/src/scout/src/Engines/CollectionEngine.php +++ b/src/scout/src/Engines/CollectionEngine.php @@ -11,7 +11,6 @@ use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Scout\Builder; use Hypervel\Scout\Contracts\SearchableInterface; -use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; use Hypervel\Support\Str; @@ -89,9 +88,9 @@ protected function searchModels(Builder $builder): EloquentCollection call_user_func($builder->callback, $query, $builder, $builder->query); }) ->when($builder->callback === null && count($builder->wheres) > 0, function ($query) use ($builder) { - foreach ($builder->wheres as $key => $value) { - if ($key !== '__soft_deleted') { - $query->where($key, $value); + foreach ($builder->wheres as $where) { + if ($where['field'] !== '__soft_deleted') { + $query->where($where['field'], $where['operator'], $where['value']); } } }) @@ -138,7 +137,7 @@ protected function searchModels(Builder $builder): EloquentCollection return false; } - if (! $builder->query) { + if ($builder->query === '') { return true; } @@ -168,12 +167,14 @@ protected function searchModels(Builder $builder): EloquentCollection */ protected function ensureSoftDeletesAreHandled(Builder $builder, EloquentBuilder $query): EloquentBuilder { - if (Arr::get($builder->wheres, '__soft_deleted') === 0) { + $softDeleteWhere = collect($builder->wheres)->firstWhere('field', '__soft_deleted'); + + if ($softDeleteWhere !== null && $softDeleteWhere['value'] === 0) { /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ return $query->withoutTrashed(); } - if (Arr::get($builder->wheres, '__soft_deleted') === 1) { + if ($softDeleteWhere !== null && $softDeleteWhere['value'] === 1) { /* @phpstan-ignore method.notFound (SoftDeletingScope adds this method) */ return $query->onlyTrashed(); } diff --git a/src/scout/src/Engines/DatabaseEngine.php b/src/scout/src/Engines/DatabaseEngine.php index 324566174..ff76468bf 100644 --- a/src/scout/src/Engines/DatabaseEngine.php +++ b/src/scout/src/Engines/DatabaseEngine.php @@ -16,7 +16,6 @@ use Hypervel\Scout\Builder; use Hypervel\Scout\Contracts\PaginatesEloquentModelsUsingDatabase; use Hypervel\Scout\Contracts\SearchableInterface; -use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; use ReflectionMethod; @@ -302,9 +301,9 @@ protected function addAdditionalConstraints(Builder $builder, EloquentBuilder $q call_user_func($builder->callback, $query, $builder, $builder->query); }) ->when($builder->callback === null && count($builder->wheres) > 0, function (EloquentBuilder $query) use ($builder): void { - foreach ($builder->wheres as $key => $value) { - if ($key !== '__soft_deleted') { - $query->where($key, '=', $value); + foreach ($builder->wheres as $where) { + if ($where['field'] !== '__soft_deleted') { + $query->where($where['field'], $where['operator'], $where['value']); } } }) @@ -328,14 +327,14 @@ protected function addAdditionalConstraints(Builder $builder, EloquentBuilder $q */ protected function constrainForSoftDeletes(Builder $builder, EloquentBuilder $query): EloquentBuilder { - $softDeletedValue = Arr::get($builder->wheres, '__soft_deleted'); + $softDeleteWhere = collect($builder->wheres)->firstWhere('field', '__soft_deleted'); - if ($softDeletedValue === 0) { + if ($softDeleteWhere !== null && $softDeleteWhere['value'] === 0) { /* @phpstan-ignore method.notFound (SoftDeletes adds this method via global scope) */ return $query->withoutTrashed(); } - if ($softDeletedValue === 1) { + if ($softDeleteWhere !== null && $softDeleteWhere['value'] === 1) { /* @phpstan-ignore method.notFound (SoftDeletes adds this method via global scope) */ return $query->onlyTrashed(); } @@ -396,7 +395,9 @@ protected function getAttributeColumns(Builder $builder, string $attributeClass) continue; } - $columns = array_merge($columns, Arr::wrap($attribute->getArguments()[0])); + /** @var SearchUsingFullText|SearchUsingPrefix $attributeInstance */ + $attributeInstance = $attribute->newInstance(); + $columns = array_merge($columns, $attributeInstance->columns); } return $this->attributeColumns[$modelClass][$attributeClass] = $columns; @@ -420,8 +421,9 @@ protected function getFullTextOptions(Builder $builder): array $reflection = new ReflectionMethod($builder->model, 'toSearchableArray'); foreach ($reflection->getAttributes(SearchUsingFullText::class) as $attribute) { - $arguments = $attribute->getArguments()[1] ?? []; - $options = array_merge($options, Arr::wrap($arguments)); + /** @var SearchUsingFullText $attributeInstance */ + $attributeInstance = $attribute->newInstance(); + $options = array_merge($options, $attributeInstance->options); } return $this->fullTextOptions[$modelClass] = $options; diff --git a/src/scout/src/Engines/MeilisearchEngine.php b/src/scout/src/Engines/MeilisearchEngine.php index 078d8d206..8adafeca5 100644 --- a/src/scout/src/Engines/MeilisearchEngine.php +++ b/src/scout/src/Engines/MeilisearchEngine.php @@ -4,6 +4,7 @@ namespace Hypervel\Scout\Engines; +use BackedEnum; use DateTimeImmutable; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -11,6 +12,7 @@ use Hypervel\Scout\Builder; use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Contracts\UpdatesIndexSettings; +use Hypervel\Scout\Jobs\RemoveableScoutCollection; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; @@ -94,7 +96,9 @@ public function delete(EloquentCollection $models): void $firstModel = $models->first(); $index = $this->meilisearch->index($firstModel->indexableAs()); - $keys = $models->map(fn (SearchableInterface $model) => $model->getScoutKey())->values()->all(); + $keys = $models instanceof RemoveableScoutCollection + ? $models->pluck($firstModel->getScoutKeyName())->values()->all() + : $models->map(fn (SearchableInterface $model) => $model->getScoutKey())->values()->all(); $index->deleteDocuments($keys); } @@ -160,18 +164,26 @@ protected function performSearch(Builder $builder, array $searchParams = []): mi protected function filters(Builder $builder): string { $filters = collect($builder->wheres) - ->map(function ($value, $key) { + ->map(function (array $where): string { + $field = $where['field']; + $operator = $where['operator']; + $value = $where['value']; + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + if (is_bool($value)) { - return sprintf('%s=%s', $key, $value ? 'true' : 'false'); + return sprintf('%s%s%s', $field, $operator, $value ? 'true' : 'false'); } if ($value === null) { - return sprintf('%s IS NULL', $key); + return sprintf('%s %s', $field, $operator === '!=' ? 'IS NOT NULL' : 'IS NULL'); } return is_numeric($value) - ? sprintf('%s=%s', $key, $value) - : sprintf('%s="%s"', $key, addcslashes((string) $value, '"\\')); + ? sprintf('%s%s%s', $field, $operator, $value) + : sprintf('%s%s"%s"', $field, $operator, addcslashes((string) $value, '"\\')); }); $whereInOperators = [ @@ -185,14 +197,18 @@ protected function filters(Builder $builder): string '%s %s [%s]', $key, $operator, - collect($values)->map(function ($value) { + collect($values)->map(function (mixed $value): string { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + if (is_bool($value)) { return $value ? 'true' : 'false'; } return filter_var($value, FILTER_VALIDATE_INT) !== false ? (string) $value - : sprintf('"%s"', $value); + : sprintf('"%s"', addcslashes((string) $value, '"\\')); })->implode(', ') )); } @@ -359,7 +375,11 @@ public function createIndex(string $name, array $options = []): mixed { try { $index = $this->meilisearch->getIndex($name); - } catch (ApiException) { + } catch (ApiException $exception) { + if ($exception->httpStatus !== 404) { + throw $exception; + } + $index = null; } @@ -367,7 +387,15 @@ public function createIndex(string $name, array $options = []): mixed return $index; } - return $this->meilisearch->createIndex($name, $options); + try { + return $this->meilisearch->createIndex($name, $options); + } catch (ApiException $exception) { + if ($exception->errorCode !== 'index_already_exists') { + throw $exception; + } + + return $this->meilisearch->index($name); + } } /** diff --git a/src/scout/src/Engines/TypesenseEngine.php b/src/scout/src/Engines/TypesenseEngine.php index 9654aae0f..c4cde81f3 100644 --- a/src/scout/src/Engines/TypesenseEngine.php +++ b/src/scout/src/Engines/TypesenseEngine.php @@ -4,6 +4,7 @@ namespace Hypervel\Scout\Engines; +use BackedEnum; use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; @@ -11,8 +12,10 @@ use Hypervel\Scout\Builder; use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Exceptions\NotSupportedException; +use Hypervel\Scout\Jobs\RemoveableScoutCollection; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use InvalidArgumentException; use stdClass; use Typesense\Client as Typesense; use Typesense\Collection as TypesenseCollection; @@ -56,8 +59,6 @@ public function update(EloquentCollection $models): void /** @var Model&SearchableInterface $firstModel */ $firstModel = $models->first(); - $collection = $this->getOrCreateCollectionFromModel($firstModel); - if ($this->usesSoftDelete($firstModel) && $this->getConfig('soft_delete', false)) { $models->each->pushSoftDeleteMetadata(); } @@ -79,7 +80,17 @@ public function update(EloquentCollection $models): void ->values() ->all(); - if (! empty($objects)) { + if (empty($objects)) { + return; + } + + $collectionName = $firstModel->indexableAs(); + $collection = $this->collection($collectionName); + + try { + $this->importDocuments($collection, $objects); + } catch (ObjectNotFound) { + $this->createCollectionFromModel($firstModel, $collectionName); $this->importDocuments($collection, $objects); } } @@ -139,11 +150,15 @@ protected function createImportSortingDataObject(array $document): stdClass */ public function delete(EloquentCollection $models): void { - $models->each(function (Model $model): void { + $models->each(function (Model $model) use ($models): void { /** @var Model&SearchableInterface $model */ + $modelId = $models instanceof RemoveableScoutCollection + ? $model->getAttribute($model->getScoutKeyName()) + : $model->getScoutKey(); + $this->deleteDocument( - $this->getOrCreateCollectionFromModel($model, null, false), - $model->getScoutKey() + $this->collection($model->indexableAs()), + $modelId, ); }); } @@ -162,11 +177,8 @@ protected function deleteDocument(TypesenseCollection $collectionIndex, mixed $m $document = $collectionIndex->getDocuments()[(string) $modelId]; try { - $document->retrieve(); - return $document->delete(); } catch (ObjectNotFound) { - // Document already gone, nothing to delete return []; } } @@ -222,11 +234,8 @@ public function paginate(Builder $builder, int $perPage, int $page): mixed */ protected function performSearch(Builder $builder, array $options = []): mixed { - $documents = $this->getOrCreateCollectionFromModel( - $builder->model, - $builder->index, - false, - )->getDocuments(); + $collectionName = $builder->index ?? $builder->model->searchableAs(); + $documents = $this->collection($collectionName)->getDocuments(); if ($builder->callback !== null) { return call_user_func($builder->callback, $documents, $builder->query, $options); @@ -235,7 +244,7 @@ protected function performSearch(Builder $builder, array $options = []): mixed try { return $documents->search($options); } catch (ObjectNotFound) { - $this->getOrCreateCollectionFromModel($builder->model, $builder->index, true); + $this->createCollectionFromModel($builder->model, $collectionName); return $documents->search($options); } @@ -341,7 +350,11 @@ public function buildSearchParameters(Builder $builder, int $page, ?int $perPage protected function filters(Builder $builder): string { $whereFilter = collect($builder->wheres) - ->map(fn (mixed $value, string $key): string => $this->parseWhereFilter($this->parseFilterValue($value), $key)) + ->map(fn (array $where): string => $this->parseWhereFilter( + $this->parseFilterValue($where['value']), + $where['field'], + $where['operator'], + )) ->values() ->implode(' && '); @@ -363,15 +376,19 @@ protected function filters(Builder $builder): string /** * Parse the given filter value. * - * @param array|bool|float|int|string $value + * @param array|BackedEnum|bool|float|int|string $value * @return array|float|int|string */ - protected function parseFilterValue(array|string|bool|int|float $value): array|string|int|float + protected function parseFilterValue(array|string|bool|int|float|BackedEnum $value): array|string|int|float { if (is_array($value)) { return array_map([$this, 'parseFilterValue'], $value); } + if ($value instanceof BackedEnum) { + return $value->value; + } + if (is_bool($value)) { return $value ? 'true' : 'false'; } @@ -384,11 +401,24 @@ protected function parseFilterValue(array|string|bool|int|float $value): array|s * * @param array|float|int|string $value */ - protected function parseWhereFilter(array|string|int|float $value, string $key): string - { + protected function parseWhereFilter( + array|string|int|float $value, + string $key, + string $operator = '=' + ): string { + $operator = match ($operator) { + '=' => ':=', + '!=' => ':!=', + '<' => ':<', + '>' => ':>', + '<=' => ':<=', + '>=' => ':>=', + default => throw new InvalidArgumentException("Unsupported Typesense filter operator [{$operator}]."), + }; + return is_array($value) - ? sprintf('%s:%s', $key, implode('', $value)) - : sprintf('%s:=%s', $key, $value); + ? sprintf('%s%s%s', $key, $operator, implode('', $value)) + : sprintf('%s%s%s', $key, $operator, $value); } /** @@ -528,7 +558,11 @@ public function getTotalCount(mixed $results): int */ public function flush(Model $model): void { - $this->getOrCreateCollectionFromModel($model)->delete(); + try { + $this->collection($model->indexableAs())->delete(); + } catch (ObjectNotFound) { + // The collection is already absent. + } } /** @@ -550,42 +584,17 @@ public function createIndex(string $name, array $options = []): mixed */ public function deleteIndex(string $name): array { - return $this->typesense->getCollections()->{$name}->delete(); + return $this->collection($name)->delete(); } /** - * Get collection from model or create new one. + * Create the model's Typesense collection. * * @param Model&SearchableInterface $model * @throws TypesenseClientError */ - protected function getOrCreateCollectionFromModel( - Model $model, - ?string $collectionName = null, - bool $indexOperation = true - ): TypesenseCollection { - if (! $indexOperation) { - $collectionName = $collectionName ?? $model->searchableAs(); - } else { - $collectionName = $model->indexableAs(); - } - - $collection = $this->typesense->getCollections()->{$collectionName}; - - if (! $indexOperation) { - return $collection; - } - - // Determine if the collection exists in Typesense - try { - $collection->retrieve(); - $collection->setExists(true); - - return $collection; - } catch (TypesenseClientError) { - // Collection doesn't exist, will create it - } - + protected function createCollectionFromModel(Model $model, string $collectionName): void + { $modelClass = get_class($model); $schema = $this->getConfig("typesense.model-settings.{$modelClass}.collection-schema", []); @@ -593,17 +602,23 @@ protected function getOrCreateCollectionFromModel( $schema = $model->typesenseCollectionSchema(); } - if (! isset($schema['name'])) { - $schema['name'] = $model->searchableAs(); - } + $schema['name'] = $collectionName; try { $this->typesense->getCollections()->create($schema); } catch (ObjectAlreadyExists) { - // Collection already exists + // Another process created the collection first. } + } - $collection->setExists(true); + /** + * Get a detached Typesense collection handle. + */ + protected function collection(string $name): TypesenseCollection + { + $collections = $this->typesense->getCollections(); + $collection = $collections[$name]; + unset($collections[$name]); return $collection; } diff --git a/src/scout/src/Jobs/MakeRangeSearchable.php b/src/scout/src/Jobs/MakeRangeSearchable.php index 711ae6ccd..9751fa50e 100644 --- a/src/scout/src/Jobs/MakeRangeSearchable.php +++ b/src/scout/src/Jobs/MakeRangeSearchable.php @@ -15,6 +15,13 @@ class MakeRangeSearchable implements ShouldQueue { use Queueable; + /** + * Indicates if the job should be marked as failed on timeout. + * + * @var bool + */ + public $failOnTimeout = true; + /** * Create a new job instance. * diff --git a/src/scout/src/Jobs/MakeSearchable.php b/src/scout/src/Jobs/MakeSearchable.php index f94c2812a..c6d931a04 100644 --- a/src/scout/src/Jobs/MakeSearchable.php +++ b/src/scout/src/Jobs/MakeSearchable.php @@ -9,12 +9,14 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Queue\Queueable; use Hypervel\Scout\Contracts\SearchableInterface; +use Hypervel\Scout\Traits\ConfiguresJobOptions; /** * Queue job that makes models searchable by updating them in the search index. */ class MakeSearchable implements ShouldQueue { + use ConfiguresJobOptions; use Queueable; /** @@ -25,6 +27,7 @@ class MakeSearchable implements ShouldQueue public function __construct( public Collection $models ) { + $this->configureJob(); } /** diff --git a/src/scout/src/Jobs/MakeSearchableUniquely.php b/src/scout/src/Jobs/MakeSearchableUniquely.php new file mode 100644 index 000000000..71c2f7306 --- /dev/null +++ b/src/scout/src/Jobs/MakeSearchableUniquely.php @@ -0,0 +1,13 @@ +models = RemoveableScoutCollection::make($models); + + $this->configureJob(); } /** @@ -43,4 +48,30 @@ public function handle(): void $firstModel->searchableUsing()->delete($this->models); } } + + /** + * Restore a queueable collection instance. + */ + protected function restoreCollection(ModelIdentifier $value): RemoveableScoutCollection + { + $class = $value->getClass(); + + if ($class === null || $value->id === []) { + return new RemoveableScoutCollection; + } + + /** @var array $ids */ + $ids = $value->id; + + return new RemoveableScoutCollection(array_map( + function (int|string $id) use ($class, $value): Model { + /** @var Model&SearchableInterface $model */ + $model = (new $class)->setConnection($value->connection); + + return $model->setKeyType(is_string($id) ? 'string' : 'int') + ->forceFill([$model->getScoutKeyName() => $id]); + }, + $ids + )); + } } diff --git a/src/scout/src/Jobs/RemoveFromSearchUniquely.php b/src/scout/src/Jobs/RemoveFromSearchUniquely.php new file mode 100644 index 000000000..7fc1033ff --- /dev/null +++ b/src/scout/src/Jobs/RemoveFromSearchUniquely.php @@ -0,0 +1,13 @@ +searchIndexShouldBeUpdated()) { return; } if (! $model->shouldBeSearchable()) { - /* @phpstan-ignore method.notFound (provided by Searchable trait) */ if ($model->wasSearchableBeforeUpdate()) { $model->unsearchable(); } @@ -128,11 +126,14 @@ public function deleted(Model $model): void return; } - /* @phpstan-ignore method.notFound (provided by Searchable trait) */ if (! $model->wasSearchableBeforeDelete()) { return; } + if ($this->usesSoftDelete($model) && $model->isForceDeleting()) { + return; + } + if ($this->usingSoftDeletes && $this->usesSoftDelete($model)) { $this->whileForcingUpdate(function () use ($model): void { $this->saved($model); diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php index 3661d66e6..471116c85 100644 --- a/src/scout/src/Searchable.php +++ b/src/scout/src/Searchable.php @@ -7,12 +7,18 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Coroutine\Coroutine; -use Hypervel\Coroutine\WaitConcurrent; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Scout\Console\ConcurrentImportRunner; +use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Engines\Engine; +use Hypervel\Scout\Events\ModelsFlushed; +use Hypervel\Scout\Events\ModelsImported; use Hypervel\Support\Collection as BaseCollection; use LogicException; @@ -24,7 +30,7 @@ trait Searchable { /** - * Coroutine-local context key for the WaitConcurrent runner used during imports. + * Coroutine-local context key for the concurrent runner used during imports. * * Coroutine-local rather than a static property so concurrent coroutines in * the same process don't share or overwrite each other's runner instances. @@ -54,6 +60,8 @@ public static function bootSearchable(): void /** * Register the searchable macros on collections. + * + * Boot-only. These macros persist for the worker lifetime and affect every subsequent request. */ public function registerSearchableMacros(): void { @@ -84,6 +92,38 @@ public function registerSearchableMacros(): void } $this->first()->syncRemoveFromSearch($this); }); + + HasManyThrough::macro('searchable', function (?int $chunk = null): void { + /** @var HasManyThrough $this */ + $chunkSize = $chunk ?? config('scout.chunk.searchable', 500); + + $this->chunkById($chunkSize, function (Collection $models): void { + /** @var Collection $models */ + $models->filter(fn ($model) => $model->shouldBeSearchable())->searchable(); + + $events = Container::getInstance()->make(Dispatcher::class); + if ($events->hasListeners(ModelsImported::class)) { + $events->dispatch(new ModelsImported($models)); + } + + Scout::reportImportProgress($models); + }); + }); + + HasManyThrough::macro('unsearchable', function (?int $chunk = null): void { + /** @var HasManyThrough $this */ + $chunkSize = $chunk ?? config('scout.chunk.unsearchable', 500); + + $this->chunkById($chunkSize, function (Collection $models): void { + /** @var Collection $models */ + $models->unsearchable(); + + $events = Container::getInstance()->make(Dispatcher::class); + if ($events->hasListeners(ModelsFlushed::class)) { + $events->dispatch(new ModelsFlushed($models)); + } + }); + }); } /** @@ -101,7 +141,7 @@ public function queueMakeSearchable(Collection $models): void ->onConnection($models->first()->syncWithSearchUsing()) ->onQueue($models->first()->syncWithSearchUsingQueue()); - if (static::getScoutConfig('queue.after_commit', false)) { + if (static::getScoutConfig('after_commit', false)) { $pendingDispatch->afterCommit(); } @@ -146,7 +186,7 @@ public function queueRemoveFromSearch(Collection $models): void ->onConnection($models->first()->syncWithSearchUsing()) ->onQueue($models->first()->syncWithSearchUsingQueue()); - if (static::getScoutConfig('queue.after_commit', false)) { + if (static::getScoutConfig('after_commit', false)) { $pendingDispatch->afterCommit(); } @@ -189,16 +229,21 @@ public function searchIndexShouldBeUpdated(): bool /** * Perform a search against the model's indexed data. * + * Models may define a protected static class-string $scoutBuilder to select a custom builder. + * * @return Builder */ public static function search(string $query = '', ?Closure $callback = null): Builder { - return new Builder( - model: new static, - query: $query, - callback: $callback, - softDelete: static::usesSoftDelete() && static::getScoutConfig('soft_delete', false) - ); + // @phpstan-ignore staticProperty.notFound (models may define the documented custom builder property) + $builder = static::$scoutBuilder ?? Builder::class; + + return Container::getInstance()->makeWith($builder, [ + 'model' => new static, + 'query' => $query, + 'callback' => $callback, + 'softDelete' => static::usesSoftDelete() && static::getScoutConfig('soft_delete', false), + ]); } /** @@ -359,12 +404,14 @@ public static function isSearchSyncingEnabled(): bool */ public static function withoutSyncingToSearch(callable $callback): mixed { + $wasDisabled = ! static::isSearchSyncingEnabled(); + static::disableSearchSyncing(); try { return $callback(); } finally { - static::enableSearchSyncing(); + $wasDisabled ? static::disableSearchSyncing() : static::enableSearchSyncing(); } } @@ -484,12 +531,12 @@ public function getScoutKeyType(): string */ protected static function dispatchSearchableJob(callable $job): void { - // Command path: use WaitConcurrent for parallel execution + // Command path: run indexing concurrently and preserve child failures. if (Scout::isImporting()) { $runner = CoroutineContext::get(self::SCOUT_RUNNER_CONTEXT_KEY); - if (! $runner instanceof WaitConcurrent) { - $runner = new WaitConcurrent( + if (! $runner instanceof ConcurrentImportRunner) { + $runner = new ConcurrentImportRunner( (int) static::getScoutConfig('command_concurrency', 50) ); CoroutineContext::set(self::SCOUT_RUNNER_CONTEXT_KEY, $runner); @@ -513,9 +560,12 @@ public static function waitForSearchableJobs(): void { $runner = CoroutineContext::get(self::SCOUT_RUNNER_CONTEXT_KEY); - if ($runner instanceof WaitConcurrent) { - $runner->wait(); - CoroutineContext::forget(self::SCOUT_RUNNER_CONTEXT_KEY); + if ($runner instanceof ConcurrentImportRunner) { + try { + $runner->wait(); + } finally { + CoroutineContext::forget(self::SCOUT_RUNNER_CONTEXT_KEY); + } } } diff --git a/src/scout/src/SearchableScope.php b/src/scout/src/SearchableScope.php index 467af61e6..6fec11a27 100644 --- a/src/scout/src/SearchableScope.php +++ b/src/scout/src/SearchableScope.php @@ -37,8 +37,7 @@ public function extend(EloquentBuilder $builder): void /** @var Model&SearchableInterface $model */ $model = $builder->getModel(); $scoutKeyName = $model->getScoutKeyName(); - // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) - $chunkSize = $chunk ?? static::getScoutConfig('chunk.searchable', 500); + $chunkSize = $chunk ?? config('scout.chunk.searchable', 500); $builder->chunkById($chunkSize, function (Collection $models) { /** @var EloquentCollection $models */ @@ -46,7 +45,7 @@ public function extend(EloquentBuilder $builder): void $models->filter(fn ($m) => $m->shouldBeSearchable())->searchable(); // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) - static::dispatchEvent(new ModelsImported($models)); + static::dispatchEvent(ModelsImported::class, $models); Scout::reportImportProgress($models); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); }); @@ -55,8 +54,7 @@ public function extend(EloquentBuilder $builder): void /** @var Model&SearchableInterface $model */ $model = $builder->getModel(); $scoutKeyName = $model->getScoutKeyName(); - // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) - $chunkSize = $chunk ?? static::getScoutConfig('chunk.unsearchable', 500); + $chunkSize = $chunk ?? config('scout.chunk.unsearchable', 500); $builder->chunkById($chunkSize, function (Collection $models) { /** @var EloquentCollection $models */ @@ -64,28 +62,22 @@ public function extend(EloquentBuilder $builder): void $models->unsearchable(); // @phpstan-ignore staticMethod.notFound (local macros retain their lexical class scope at runtime) - static::dispatchEvent(new ModelsFlushed($models)); + static::dispatchEvent(ModelsFlushed::class, $models); }, $builder->qualifyColumn($scoutKeyName), $scoutKeyName); }); } /** - * Get a Scout configuration value. + * Dispatch an event when the dispatcher has listeners for it. + * + * @param class-string $event */ - protected static function getScoutConfig(string $key, mixed $default = null): mixed + protected static function dispatchEvent(string $event, Collection $models): void { - return Container::getInstance() - ->make('config') - ->get("scout.{$key}", $default); - } + $events = Container::getInstance()->make(Dispatcher::class); - /** - * Dispatch an event through the event dispatcher. - */ - protected static function dispatchEvent(object $event): void - { - Container::getInstance() - ->make(Dispatcher::class) - ->dispatch($event); + if ($events->hasListeners($event)) { + $events->dispatch(new $event($models)); + } } } diff --git a/src/scout/src/Traits/ConfiguresJobOptions.php b/src/scout/src/Traits/ConfiguresJobOptions.php new file mode 100644 index 000000000..05390bf0c --- /dev/null +++ b/src/scout/src/Traits/ConfiguresJobOptions.php @@ -0,0 +1,57 @@ + + */ + public $backoff; + + /** + * The maximum number of unhandled exceptions to allow before failing. + * + * @var null|int + */ + public $maxExceptions; + + /** + * Indicates if the job should be marked as failed on timeout. + * + * @var bool + */ + public $failOnTimeout = true; + + /** + * Configure the job. + */ + protected function configureJob(): void + { + if (! isset($this->tries) && ($tries = config('scout.jobs.tries')) !== null) { + $this->tries = $tries; + } + + if (! isset($this->backoff) + && ! method_exists($this, 'backoff') + && ($backoff = config('scout.jobs.backoff')) !== null) { + $this->backoff = $backoff; + } + + if (! isset($this->maxExceptions) + && ($maxExceptions = config('scout.jobs.max_exceptions')) !== null) { + $this->maxExceptions = $maxExceptions; + } + } +} diff --git a/src/scout/src/Traits/UniqueByScoutKeys.php b/src/scout/src/Traits/UniqueByScoutKeys.php new file mode 100644 index 000000000..8863efc0b --- /dev/null +++ b/src/scout/src/Traits/UniqueByScoutKeys.php @@ -0,0 +1,31 @@ +models->getQueueableClass(), + $this->models->map(function (Model&SearchableInterface $model) { + return $model->getScoutKey(); + })->sort()->values()->all(), + ], JSON_THROW_ON_ERROR)); + } +} diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 0d0fb5ac7..3536ec56b 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -23,6 +23,7 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection as BaseCollection; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Mockery as m; use PDO; use stdClass; @@ -483,20 +484,27 @@ public function testChunkCanBeStoppedByReturningFalse() }); } - public function testChunkWithCountZero() + public function testChunkRejectsNonpositiveCounts() { $builder = m::mock(Builder::class . '[getOffset,getLimit,offset,limit,get]', [$this->getMockQueryBuilder()]); $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; - $builder->shouldReceive('getOffset')->once()->andReturn(null); - $builder->shouldReceive('getLimit')->once()->andReturn(null); + $builder->shouldReceive('getOffset')->never(); + $builder->shouldReceive('getLimit')->never(); $builder->shouldReceive('offset')->never(); $builder->shouldReceive('limit')->never(); $builder->shouldReceive('get')->never(); - $builder->chunk(0, function () { - $this->fail('Should not be called.'); - }); + foreach ([0, -1] as $count) { + try { + $builder->chunk($count, function () { + $this->fail('Should not be called.'); + }); + $this->fail('The nonpositive chunk size was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('The chunk size should be at least 1', $exception->getMessage()); + } + } } public function testChunkPaginatesUsingIdWithLastChunkComplete() @@ -546,22 +554,26 @@ public function testChunkPaginatesUsingIdWithLastChunkPartial() }, 'someIdField'); } - public function testChunkPaginatesUsingIdWithCountZero() + public function testChunkByIdRejectsNonpositiveCounts() { $builder = m::mock(Builder::class . '[getOffset,getLimit,forPageAfterId,get]', [$this->getMockQueryBuilder()]); $builder->getQuery()->orders[] = ['column' => 'foobar', 'direction' => 'asc']; - $builder->shouldReceive('getOffset')->andReturnNull(); - $builder->shouldReceive('getLimit')->andReturnNull(); + $builder->shouldReceive('getOffset')->never(); + $builder->shouldReceive('getLimit')->never(); $builder->shouldReceive('forPageAfterId')->never(); $builder->shouldReceive('get')->never(); - $callbackAssertor = m::mock(stdClass::class); - $callbackAssertor->shouldReceive('doSomething')->never(); - - $builder->chunkById(0, function () { - $this->fail('Should never be called.'); - }, 'someIdField'); + foreach ([0, -1] as $count) { + try { + $builder->chunkById($count, function () { + $this->fail('Should never be called.'); + }, 'someIdField'); + $this->fail('The nonpositive chunk size was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('The chunk size should be at least 1', $exception->getMessage()); + } + } } public function testLazyWithLastChunkComplete() diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 1a13d7102..09f2187e1 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentModelTest; +use ArgumentCountError; use Carbon\CarbonInterface; use DateTime; use DateTimeImmutable; @@ -4405,6 +4406,14 @@ public function testCollectedByAttribute(): void $this->assertInstanceOf(CustomEloquentCollection::class, $collection); } + public function testPositionalCollectedByAttribute(): void + { + $model = new ModelWithPositionalCollectedByAttribute; + $collection = $model->newCollection([$model]); + + $this->assertInstanceOf(CustomEloquentCollection::class, $collection); + } + public function testCollectedByAttributeIsInherited(): void { $model = new ModelChildWithCollectedByAttribute; @@ -4437,6 +4446,13 @@ public function testCollectedByAttributeOnChildOverridesParent(): void $this->assertInstanceOf(CustomChildEloquentCollection::class, $collection); } + public function testMalformedCollectedByAttributeFailsFast(): void + { + $this->expectException(ArgumentCountError::class); + + (new ModelWithMalformedCollectedByAttribute)->newCollection(); + } + public function testUseFactoryAttribute() { $model = new ModelWithUseFactoryAttribute; @@ -5352,11 +5368,16 @@ public function setFullAddressAttribute($fullAddress) } } -#[CollectedBy(CustomEloquentCollection::class)] +#[CollectedBy(collectionClass: CustomEloquentCollection::class)] class ModelWithCollectedByAttribute extends Model { } +#[CollectedBy(CustomEloquentCollection::class)] +class ModelWithPositionalCollectedByAttribute extends Model +{ +} + class ModelChildWithCollectedByAttribute extends ModelWithCollectedByAttribute { } @@ -5377,11 +5398,16 @@ class ModelConcreteChild extends ModelAbstractParent { } -#[CollectedBy(CustomChildEloquentCollection::class)] +#[CollectedBy(collectionClass: CustomChildEloquentCollection::class)] class ModelChildWithCollectedByAttributeOverride extends ModelWithCollectedByAttribute { } +#[CollectedBy] +class ModelWithMalformedCollectedByAttribute extends Model +{ +} + class CustomEloquentCollection extends Collection { } diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 103d8688d..5fc7c34bf 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -2485,6 +2485,11 @@ public function testForPageBeforeId() $builder = $this->getBuilder(); $builder->select('*')->from('users')->forPageBeforeId(15, 0); $this->assertSame('select * from "users" where "id" < ? order by "id" desc limit 15', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPageBeforeId(15, '019abc'); + $this->assertSame('select * from "users" where "id" < ? order by "id" desc limit 15', $builder->toSql()); + $this->assertSame(['019abc'], $builder->getBindings()); } public function testForPageAfterId() @@ -2496,6 +2501,11 @@ public function testForPageAfterId() $builder = $this->getBuilder(); $builder->select('*')->from('users')->forPageAfterId(15, 0); $this->assertSame('select * from "users" where "id" > ? order by "id" asc limit 15', $builder->toSql()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->forPageAfterId(15, '019abc'); + $this->assertSame('select * from "users" where "id" > ? order by "id" asc limit 15', $builder->toSql()); + $this->assertSame(['019abc'], $builder->getBindings()); } public function testGetCountForPaginationWithBindings() @@ -5551,20 +5561,27 @@ public function testChunkCanBeStoppedByReturningFalse() }); } - public function testChunkWithCountZero() + public function testChunkRejectsNonpositiveCounts() { $builder = $this->getMockQueryBuilder(); $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; - $builder->shouldReceive('getOffset')->once()->andReturnNull(); - $builder->shouldReceive('getLimit')->once()->andReturnNull(); + $builder->shouldReceive('getOffset')->never(); + $builder->shouldReceive('getLimit')->never(); $builder->shouldReceive('offset')->never(); $builder->shouldReceive('limit')->never(); $builder->shouldReceive('get')->never(); - $builder->chunk(0, function () { - $this->fail('Should never be called.'); - }); + foreach ([0, -1] as $count) { + try { + $builder->chunk($count, function () { + $this->fail('Should never be called.'); + }); + $this->fail('The nonpositive chunk size was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('The chunk size should be at least 1', $exception->getMessage()); + } + } } public function testChunkByIdOnArrays() @@ -5633,17 +5650,26 @@ public function testChunkPaginatesUsingIdWithLastChunkPartial() }, 'someIdField'); } - public function testChunkPaginatesUsingIdWithCountZero() + public function testChunkByIdRejectsNonpositiveCounts() { $builder = $this->getMockQueryBuilder(); $builder->orders[] = ['column' => 'foobar', 'direction' => 'asc']; + $builder->shouldReceive('getOffset')->never(); + $builder->shouldReceive('getLimit')->never(); $builder->shouldReceive('forPageAfterId')->never(); $builder->shouldReceive('get')->never(); - $builder->chunkById(0, function () { - $this->fail('Should never be called.'); - }, 'someIdField'); + foreach ([0, -1] as $count) { + try { + $builder->chunkById($count, function () { + $this->fail('Should never be called.'); + }, 'someIdField'); + $this->fail('The nonpositive chunk size was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('The chunk size should be at least 1', $exception->getMessage()); + } + } } public function testChunkPaginatesUsingIdWithAlias() diff --git a/tests/Database/Eloquent/Concerns/HasUuidsTest.php b/tests/Database/Eloquent/Concerns/HasUuidsTest.php index 818c28e46..639054cdd 100644 --- a/tests/Database/Eloquent/Concerns/HasUuidsTest.php +++ b/tests/Database/Eloquent/Concerns/HasUuidsTest.php @@ -76,6 +76,22 @@ public function testDoesNotOverwriteIdOnExistingRecord(): void $this->assertSame($explicitId, $model->id); $this->assertSame('Original', $model->name); } + + public function testChunkByIdDescTraversesMultiplePagesOfUuidKeys(): void + { + for ($i = 1; $i <= 5; ++$i) { + HasUuidsTestModel::create(['name' => "Model {$i}"]); + } + + $expected = HasUuidsTestModel::query()->orderByDesc('id')->pluck('id')->all(); + $actual = []; + + HasUuidsTestModel::query()->chunkByIdDesc(2, function ($models) use (&$actual): void { + array_push($actual, ...$models->pluck('id')->all()); + }); + + $this->assertSame($expected, $actual); + } } class HasUuidsTestModel extends Model diff --git a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php index d7faa50ee..e83bfee72 100644 --- a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php +++ b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Foundation\Testing\Concerns; use Algolia\AlgoliaSearch\Algolia; +use Algolia\AlgoliaSearch\Api\SearchClient as AlgoliaSearchClient; use Algolia\AlgoliaSearch\Http\HttpClientInterface; use Algolia\AlgoliaSearch\Http\Psr7\Response; use Hypervel\Foundation\Testing\Concerns\InteractsWithAlgolia; @@ -14,6 +15,11 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithTypesense; use Hypervel\Support\Env; use Hypervel\Tests\TestCase; +use Meilisearch\Client as MeilisearchClient; +use Meilisearch\Contracts\IndexesResults; +use Meilisearch\Contracts\TasksResults; +use Meilisearch\Endpoints\Indexes; +use Meilisearch\Exceptions\TimeOutException; use Mockery as m; use RuntimeException; use Throwable; @@ -152,6 +158,138 @@ public function testMeilisearchPreservesExplicitPrefixBeforeInitializingClient() } } + public function testMeilisearchWaitAcceptsSuccessfulTasks(): void + { + $client = m::mock(MeilisearchClient::class); + $client->shouldReceive('getTasks') + ->once() + ->andReturn(new TasksResults([ + 'results' => [['uid' => 17, 'status' => 'enqueued']], + ])); + $client->shouldReceive('waitForTask') + ->once() + ->with(17, 5000) + ->andReturn(['uid' => 17, 'status' => 'succeeded']); + + $harness = new MeilisearchOptInHarness; + $harness->useClient($client); + $harness->waitForTasks(); + } + + public function testMeilisearchWaitRejectsFailedTasks(): void + { + $client = m::mock(MeilisearchClient::class); + $client->shouldReceive('getTasks') + ->once() + ->andReturn(new TasksResults([ + 'results' => [['uid' => 17, 'status' => 'processing']], + ])); + $client->shouldReceive('waitForTask') + ->once() + ->with(17, 5000) + ->andReturn([ + 'uid' => 17, + 'status' => 'failed', + 'error' => ['message' => 'Document identifier is invalid.'], + ]); + + $harness = new MeilisearchOptInHarness; + $harness->useClient($client); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Document identifier is invalid.'); + + $harness->waitForTasks(); + } + + public function testMeilisearchWaitPropagatesTimeouts(): void + { + $timeout = new TimeOutException; + $client = m::mock(MeilisearchClient::class); + $client->shouldReceive('getTasks') + ->once() + ->andReturn(new TasksResults([ + 'results' => [['uid' => 17, 'status' => 'enqueued']], + ])); + $client->shouldReceive('waitForTask') + ->once() + ->with(17, 5000) + ->andThrow($timeout); + + $harness = new MeilisearchOptInHarness; + $harness->useClient($client); + + $this->expectExceptionObject($timeout); + + $harness->waitForTasks(); + } + + public function testMeilisearchCleanupWaitsForExactDeletionTasks(): void + { + $index = m::mock(Indexes::class); + $index->shouldReceive('getUid')->andReturn('test_users'); + + $client = m::mock(MeilisearchClient::class); + $client->shouldReceive('getIndexes') + ->once() + ->andReturn(new IndexesResults([ + 'results' => [$index], + 'offset' => 0, + 'limit' => 20, + 'total' => 1, + ])); + $client->shouldReceive('deleteIndex') + ->once() + ->with('test_users') + ->andReturn(['taskUid' => 29]); + $client->shouldReceive('waitForTask') + ->once() + ->with(29, 5000) + ->andReturn(['uid' => 29, 'status' => 'succeeded']); + + $harness = new MeilisearchOptInHarness; + $harness->usePrefix('test_'); + $harness->useClient($client); + $harness->runCleanup(); + } + + public function testMeilisearchCleanupPropagatesDeletionTaskFailures(): void + { + $index = m::mock(Indexes::class); + $index->shouldReceive('getUid')->andReturn('test_users'); + + $client = m::mock(MeilisearchClient::class); + $client->shouldReceive('getIndexes') + ->once() + ->andReturn(new IndexesResults([ + 'results' => [$index], + 'offset' => 0, + 'limit' => 20, + 'total' => 1, + ])); + $client->shouldReceive('deleteIndex') + ->once() + ->with('test_users') + ->andReturn(['taskUid' => 29]); + $client->shouldReceive('waitForTask') + ->once() + ->with(29, 5000) + ->andReturn([ + 'uid' => 29, + 'status' => 'failed', + 'error' => ['message' => 'Index deletion failed.'], + ]); + + $harness = new MeilisearchOptInHarness; + $harness->usePrefix('test_'); + $harness->useClient($client); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Index deletion failed.'); + + $harness->runCleanup(); + } + public function testTypesenseSkipsBeforeInitializingClientWhenHostIsNotConfigured(): void { $this->setExternalServiceEnvironmentValue('TYPESENSE_HOST', null); @@ -293,6 +431,55 @@ public function testAlgoliaPreservesAnExplicitPrefix(): void } } + public function testAlgoliaCleanupWaitsForExactDeletionTasks(): void + { + $client = m::mock(AlgoliaSearchClient::class); + $client->shouldReceive('listIndices') + ->once() + ->andReturn(['items' => [ + ['name' => 'test_users'], + ['name' => 'production_users'], + ]]); + $client->shouldReceive('deleteIndex') + ->once() + ->with('test_users') + ->andReturn(['taskID' => 31]); + $client->shouldReceive('waitForTask') + ->once() + ->with('test_users', 31) + ->andReturn(['status' => 'published']); + + $harness = new AlgoliaOptInHarness; + $harness->usePrefix('test_'); + $harness->useClient($client); + $harness->runCleanup(); + } + + public function testAlgoliaCleanupPropagatesDeletionTaskFailures(): void + { + $failure = new RuntimeException('Index deletion failed.'); + $client = m::mock(AlgoliaSearchClient::class); + $client->shouldReceive('listIndices') + ->once() + ->andReturn(['items' => [['name' => 'test_users']]]); + $client->shouldReceive('deleteIndex') + ->once() + ->with('test_users') + ->andReturn(['taskID' => 31]); + $client->shouldReceive('waitForTask') + ->once() + ->with('test_users', 31) + ->andThrow($failure); + + $harness = new AlgoliaOptInHarness; + $harness->usePrefix('test_'); + $harness->useClient($client); + + $this->expectExceptionObject($failure); + + $harness->runCleanup(); + } + public function testAlgoliaRestoresTheExactHttpClientWhenSetupFails(): void { $this->setExternalServiceEnvironmentValue('ALGOLIA_APP_ID', 'application-id'); @@ -473,6 +660,30 @@ public function runSetUp(): void $this->setUpInteractsWithMeilisearch(); } + /** + * Use the given Meilisearch client. + */ + public function useClient(MeilisearchClient $client): void + { + $this->meilisearch = $client; + } + + /** + * Wait for Meilisearch tasks to complete. + */ + public function waitForTasks(): void + { + $this->waitForMeilisearchTasks(); + } + + /** + * Clean up Meilisearch indexes. + */ + public function runCleanup(): void + { + $this->cleanupMeilisearchIndexes(); + } + /** * Initialize the Meilisearch client. */ @@ -583,6 +794,22 @@ public function runTearDown(): void $this->tearDownInteractsWithAlgolia(); } + /** + * Use the given Algolia client. + */ + public function useClient(AlgoliaSearchClient $client): void + { + $this->algolia = $client; + } + + /** + * Clean up Algolia indexes. + */ + public function runCleanup(): void + { + $this->cleanupAlgoliaIndices(); + } + /** * Use the given Algolia HTTP client. */ diff --git a/tests/Integration/Scout/Algolia/AlgoliaEngineIntegrationTest.php b/tests/Integration/Scout/Algolia/AlgoliaEngineIntegrationTest.php index ec6908d8a..56b991f52 100644 --- a/tests/Integration/Scout/Algolia/AlgoliaEngineIntegrationTest.php +++ b/tests/Integration/Scout/Algolia/AlgoliaEngineIntegrationTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Integration\Scout\Algolia; use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Scout\Jobs\RemoveFromSearch; +use Hypervel\Tests\Scout\Models\CustomScoutKeyModel; use Hypervel\Tests\Scout\Models\SearchableModel; use Throwable; @@ -57,6 +59,27 @@ public function testDeleteRemovesModelsFromAlgolia(): void $this->assertCount(0, $hits); } + public function testQueuedRemovalDeletesTheExactCustomScoutKey(): void + { + $model = CustomScoutKeyModel::withoutSyncingToSearch(function () { + return CustomScoutKeyModel::create(['title' => 'Custom key', 'body' => 'Content']); + }); + + $this->engine->update(new EloquentCollection([$model])); + $this->pollSearch($model->indexableAs(), 'Custom', expectedCount: 1); + + $job = new RemoveFromSearch(new EloquentCollection([$model])); + CustomScoutKeyModel::query()->whereKey($model->getKey())->delete(); + + /** @var RemoveFromSearch $restored */ + $restored = unserialize(serialize($job)); + $restored->handle(); + + $hits = $this->pollSearch($model->indexableAs(), 'Custom', expectedCount: 0); + + $this->assertCount(0, $hits); + } + public function testFlushClearsEntireIndex(): void { $models = SearchableModel::withoutSyncingToSearch(fn () => new EloquentCollection([ diff --git a/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php b/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php index fb4675364..373f6962e 100644 --- a/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php @@ -21,8 +21,7 @@ protected function setUpInCoroutine(): void } /** - * Configure attributesForFaceting so Algolia accepts numericFilters - * on id/title/body. Required before indexing data. + * Configure attributesForFaceting so Algolia accepts filters on id/title/body. */ protected function configureFilterableIndex(): void { @@ -97,6 +96,51 @@ public function testWhereNotInExcludesSpecifiedValues(): void $this->assertFalse($results->contains('id', $second->id)); } + public function testComparisonFiltersAndEscapedStringValuesReachAlgolia(): void + { + $models = SearchableModel::withoutSyncingToSearch(fn () => new EloquentCollection([ + SearchableModel::create(['id' => 101, 'title' => 'A "quoted" \ guide', 'body' => 'Body']), + SearchableModel::create(['id' => 102, 'title' => 'Other', 'body' => 'Body']), + SearchableModel::create(['id' => 103, 'title' => 'Third', 'body' => 'Body']), + ])); + + $this->engine->update($models); + $this->pollSearch($models->first()->searchableAs(), '', 3); + + $results = SearchableModel::search('') + ->where('id', '>', 101) + ->where('id', '!=', 103) + ->get(); + + $this->assertSame([102], $results->pluck('id')->all()); + + $results = SearchableModel::search('') + ->where('title', 'A "quoted" \ guide') + ->get(); + + $this->assertSame([101], $results->pluck('id')->all()); + } + + public function testBackedEnumsRetainTheirNativeFilterValues(): void + { + $models = SearchableModel::withoutSyncingToSearch(fn () => new EloquentCollection([ + SearchableModel::create(['id' => 201, 'title' => 'Enum target', 'body' => 'Body']), + SearchableModel::create(['id' => 202, 'title' => 'Other', 'body' => 'Body']), + ])); + + $this->engine->update($models); + $this->pollSearch($models->first()->searchableAs(), '', 2); + + $this->assertSame( + [201], + SearchableModel::search('')->where('id', AlgoliaFilterId::Target)->get()->pluck('id')->all() + ); + $this->assertSame( + [201], + SearchableModel::search('')->where('title', AlgoliaFilterTitle::Target)->get()->pluck('id')->all() + ); + } + /** * Poll an Algolia index until the search returns the expected hit count. * @@ -125,3 +169,13 @@ protected function pollSearch(string $index, string $query, int $expectedCount, return $hits; } } + +enum AlgoliaFilterId: int +{ + case Target = 201; +} + +enum AlgoliaFilterTitle: string +{ + case Target = 'Enum target'; +} diff --git a/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php index fe8e459b8..e67dbb8c5 100644 --- a/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Integration\Scout\Meilisearch; use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Scout\Jobs\RemoveFromSearch; +use Hypervel\Tests\Scout\Models\CustomScoutKeyModel; use Hypervel\Tests\Scout\Models\SearchableModel; /** @@ -61,6 +63,31 @@ public function testDeleteRemovesModelsFromMeilisearch(): void $this->assertCount(0, $results->getHits()); } + public function testQueuedRemovalDeletesTheExactCustomScoutKey(): void + { + $model = CustomScoutKeyModel::create(['title' => 'Custom key', 'body' => 'Content']); + + $this->engine->update(new EloquentCollection([$model])); + $this->waitForMeilisearchTasks(); + + $results = $this->meilisearch->index($model->indexableAs())->search(''); + + $this->assertCount(1, $results->getHits()); + $this->assertSame($model->getScoutKey(), $results->getHits()[0][$model->getScoutKeyName()]); + + $job = new RemoveFromSearch(new EloquentCollection([$model])); + CustomScoutKeyModel::query()->whereKey($model->getKey())->delete(); + + /** @var RemoveFromSearch $restored */ + $restored = unserialize(serialize($job)); + $restored->handle(); + $this->waitForMeilisearchTasks(); + + $results = $this->meilisearch->index($model->indexableAs())->search(''); + + $this->assertCount(0, $results->getHits()); + } + public function testSearchReturnsMatchingResults(): void { SearchableModel::create(['title' => 'PHP Programming', 'body' => 'Learn PHP']); diff --git a/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php index 522322773..dcafd1f4b 100644 --- a/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php @@ -143,4 +143,49 @@ public function testCombinedWhereAndWhereIn(): void $this->assertTrue($results->contains('id', $model1->id)); $this->assertTrue($results->contains('id', $model2->id)); } + + public function testComparisonFiltersReachMeilisearch(): void + { + SearchableModel::create(['id' => 301, 'title' => 'First', 'body' => 'Body']); + SearchableModel::create(['id' => 302, 'title' => 'Second', 'body' => 'Body']); + SearchableModel::create(['id' => 303, 'title' => 'Third', 'body' => 'Body']); + + $this->engine->update(SearchableModel::query()->get()); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->where('id', '>', 301) + ->where('id', '!=', 303) + ->get(); + + $this->assertSame([302], $results->pluck('id')->all()); + } + + public function testBackedEnumsAndEscapedSetValuesReachMeilisearch(): void + { + SearchableModel::create(['id' => 401, 'title' => 'A "quoted" title', 'body' => 'Body']); + SearchableModel::create(['id' => 402, 'title' => 'A \ path', 'body' => 'Body']); + SearchableModel::create(['id' => 403, 'title' => 'Other', 'body' => 'Body']); + + $this->engine->update(SearchableModel::query()->get()); + $this->waitForMeilisearchTasks(); + + $results = SearchableModel::search('') + ->whereIn('title', [MeilisearchFilterTitle::Quoted, MeilisearchFilterTitle::Path]) + ->whereNotIn('id', [MeilisearchFilterId::Other]) + ->get(); + + $this->assertSame([401, 402], $results->pluck('id')->sort()->values()->all()); + } +} + +enum MeilisearchFilterId: int +{ + case Other = 403; +} + +enum MeilisearchFilterTitle: string +{ + case Quoted = 'A "quoted" title'; + case Path = 'A \ path'; } diff --git a/tests/Integration/Scout/Meilisearch/MeilisearchScoutIntegrationTestCase.php b/tests/Integration/Scout/Meilisearch/MeilisearchScoutIntegrationTestCase.php index 2abae4cd7..e829a7160 100644 --- a/tests/Integration/Scout/Meilisearch/MeilisearchScoutIntegrationTestCase.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchScoutIntegrationTestCase.php @@ -47,6 +47,6 @@ protected function migrateFreshUsing(): array */ protected function waitForMeilisearchTasks(int $timeoutMs = 10000): void { - $this->waitForTasks($timeoutMs); + parent::waitForMeilisearchTasks($timeoutMs); } } diff --git a/tests/Integration/Scout/Typesense/TypesenseEngineIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseEngineIntegrationTest.php index 3c42ba8f6..5a8838081 100644 --- a/tests/Integration/Scout/Typesense/TypesenseEngineIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseEngineIntegrationTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Scout\Typesense; use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Scout\Jobs\RemoveFromSearch; use Hypervel\Tests\Scout\Models\TypesenseSearchableModel; /** @@ -181,4 +182,80 @@ public function testKeysReturnsScoutKeys(): void $this->assertCount(2, $keys); } + + public function testResolvedWriteTargetOverridesSchemaName(): void + { + $model = TypesenseReadWriteSearchableModel::create([ + 'title' => 'Write target', + 'body' => 'Content', + ]); + + $this->engine->update(new EloquentCollection([$model])); + + $results = TypesenseReadWriteSearchableModel::search('Write') + ->within($model->indexableAs()) + ->get(); + + $this->assertSame([$model->id], $results->pluck('id')->all()); + } + + public function testQueuedRemovalDeletesStoredCustomScoutKeyAfterModelIsGone(): void + { + $model = TypesenseCustomScoutKeyModel::withoutSyncingToSearch(function () { + return TypesenseCustomScoutKeyModel::create([ + 'title' => 'Custom key', + 'body' => 'Content', + ]); + }); + + $this->engine->update(new EloquentCollection([$model])); + + $document = $this->typesense->collections[$model->indexableAs()] + ->documents[(string) $model->getScoutKey()] + ->retrieve(); + + $this->assertSame($model->getScoutKey(), $document['id']); + + $job = new RemoveFromSearch(new EloquentCollection([$model])); + + TypesenseCustomScoutKeyModel::query()->whereKey($model->getKey())->delete(); + + unserialize(serialize($job))->handle(); + + $this->expectException(\Typesense\Exceptions\ObjectNotFound::class); + + $this->typesense->collections[$model->indexableAs()] + ->documents[(string) $model->getScoutKey()] + ->retrieve(); + } +} + +class TypesenseReadWriteSearchableModel extends TypesenseSearchableModel +{ + public function indexableAs(): string + { + return $this->searchableAs() . '_write'; + } + + public function typesenseCollectionSchema(): array + { + return array_merge(parent::typesenseCollectionSchema(), [ + 'name' => $this->searchableAs() . '_stale', + ]); + } +} + +class TypesenseCustomScoutKeyModel extends TypesenseSearchableModel +{ + public function getScoutKey(): mixed + { + return 'custom-key.' . $this->id; + } + + public function toSearchableArray(): array + { + return array_merge(parent::toSearchableArray(), [ + 'id' => (string) $this->getScoutKey(), + ]); + } } diff --git a/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php index d1123f00d..bc3f581a3 100644 --- a/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php +++ b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Tests\Scout\Models\TypesenseSearchableModel; +use InvalidArgumentException; /** * Integration tests for Typesense filtering operations. @@ -113,4 +114,55 @@ public function testCombinedWhereAndWhereIn(): void $this->assertTrue($results->contains('id', $model1->id)); $this->assertTrue($results->contains('id', $model2->id)); } + + public function testComparisonFiltersReachTypesense(): void + { + TypesenseSearchableModel::create(['id' => 501, 'title' => 'First', 'body' => 'Body']); + TypesenseSearchableModel::create(['id' => 502, 'title' => 'Second', 'body' => 'Body']); + TypesenseSearchableModel::create(['id' => 503, 'title' => 'Third', 'body' => 'Body']); + + $this->engine->update(TypesenseSearchableModel::query()->get()); + + $results = TypesenseSearchableModel::search('') + ->where('ranking', '>', 501) + ->where('ranking', '!=', 503) + ->get(); + + $this->assertSame([502], $results->pluck('id')->all()); + } + + public function testBackedEnumsRetainTheirNativeFilterValues(): void + { + TypesenseSearchableModel::create(['id' => 601, 'title' => 'Enum target', 'body' => 'Body']); + TypesenseSearchableModel::create(['id' => 602, 'title' => 'Other', 'body' => 'Body']); + + $this->engine->update(TypesenseSearchableModel::query()->get()); + + $this->assertSame( + [601], + TypesenseSearchableModel::search('')->where('ranking', TypesenseFilterRanking::Target)->get()->pluck('id')->all() + ); + $this->assertSame( + [601], + TypesenseSearchableModel::search('')->where('title', TypesenseFilterTitle::Target)->get()->pluck('id')->all() + ); + } + + public function testUnsupportedComparisonOperatorIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported Typesense filter operator [<>].'); + + TypesenseSearchableModel::search('')->where('ranking', '<>', 1)->get(); + } +} + +enum TypesenseFilterRanking: int +{ + case Target = 601; +} + +enum TypesenseFilterTitle: string +{ + case Target = 'Enum target'; } diff --git a/tests/Scout/Feature/CollectionEngineTest.php b/tests/Scout/Feature/CollectionEngineTest.php index 693423e2c..155f96175 100644 --- a/tests/Scout/Feature/CollectionEngineTest.php +++ b/tests/Scout/Feature/CollectionEngineTest.php @@ -10,7 +10,7 @@ class CollectionEngineTest extends ScoutTestCase { - public function testSearchReturnsMatchingModels() + public function testSearchReturnsMatchingModels(): void { SearchableModel::create(['title' => 'Hello World', 'body' => 'This is a test']); SearchableModel::create(['title' => 'Foo Bar', 'body' => 'Another test']); @@ -22,7 +22,7 @@ public function testSearchReturnsMatchingModels() $this->assertEquals('Hello World', $results->first()->title); } - public function testSearchReturnsAllModelsWithEmptyQuery() + public function testSearchReturnsAllModelsWithEmptyQuery(): void { SearchableModel::create(['title' => 'First', 'body' => 'Body 1']); SearchableModel::create(['title' => 'Second', 'body' => 'Body 2']); @@ -33,7 +33,7 @@ public function testSearchReturnsAllModelsWithEmptyQuery() $this->assertCount(3, $results); } - public function testSearchWithWhereClause() + public function testSearchWithWhereClause(): void { $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); $model2 = SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); @@ -46,7 +46,22 @@ public function testSearchWithWhereClause() $this->assertEquals($model1->id, $results->first()->id); } - public function testSearchWithWhereInClause() + public function testSearchWithMultipleComparisonsOnTheSameField(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + $second = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + $third = SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + SearchableModel::create(['title' => 'Fourth', 'body' => 'Body']); + + $results = SearchableModel::search('') + ->where('id', '>=', $second->id) + ->where('id', '<=', $third->id) + ->get(); + + $this->assertSame([$third->id, $second->id], $results->pluck('id')->all()); + } + + public function testSearchWithWhereInClause(): void { $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); $model2 = SearchableModel::create(['title' => 'Test B', 'body' => 'Body']); @@ -59,7 +74,7 @@ public function testSearchWithWhereInClause() $this->assertCount(2, $results); } - public function testSearchWithLimit() + public function testSearchWithLimit(): void { SearchableModel::create(['title' => 'First', 'body' => 'Body']); SearchableModel::create(['title' => 'Second', 'body' => 'Body']); @@ -70,7 +85,7 @@ public function testSearchWithLimit() $this->assertCount(2, $results); } - public function testSearchWithPagination() + public function testSearchWithPagination(): void { for ($i = 1; $i <= 10; ++$i) { SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); @@ -84,7 +99,7 @@ public function testSearchWithPagination() $this->assertEquals(10, $page1->total()); } - public function testSearchWithOrderBy() + public function testSearchWithOrderBy(): void { SearchableModel::create(['title' => 'B Item', 'body' => 'Body']); SearchableModel::create(['title' => 'A Item', 'body' => 'Body']); @@ -98,7 +113,7 @@ public function testSearchWithOrderBy() $this->assertEquals('C Item', $results->last()->title); } - public function testSearchMatchesInBody() + public function testSearchMatchesInBody(): void { SearchableModel::create(['title' => 'No match', 'body' => 'The quick brown fox']); SearchableModel::create(['title' => 'Also no match', 'body' => 'Lazy dog']); @@ -109,7 +124,7 @@ public function testSearchMatchesInBody() $this->assertEquals('No match', $results->first()->title); } - public function testSearchIsCaseInsensitive() + public function testSearchIsCaseInsensitive(): void { SearchableModel::create(['title' => 'UPPERCASE', 'body' => 'Body']); SearchableModel::create(['title' => 'lowercase', 'body' => 'Body']); @@ -121,7 +136,18 @@ public function testSearchIsCaseInsensitive() $this->assertCount(1, $results); } - public function testUpdateAndDeleteAreNoOps() + public function testSearchTreatsStringZeroAsAQuery(): void + { + SearchableModel::create(['title' => 'Contains 0', 'body' => 'Body']); + SearchableModel::create(['title' => 'No match', 'body' => 'Body']); + + $results = SearchableModel::search('0')->get(); + + $this->assertCount(1, $results); + $this->assertSame('Contains 0', $results->first()->title); + } + + public function testUpdateAndDeleteAreNoOps(): void { $model = SearchableModel::create(['title' => 'Test', 'body' => 'Body']); $engine = new CollectionEngine; @@ -134,7 +160,7 @@ public function testUpdateAndDeleteAreNoOps() $this->assertTrue(true); } - public function testCreateAndDeleteIndexAreNoOps() + public function testCreateAndDeleteIndexAreNoOps(): void { $engine = new CollectionEngine; @@ -142,7 +168,7 @@ public function testCreateAndDeleteIndexAreNoOps() $this->assertNull($engine->deleteIndex('test')); } - public function testGetTotalCountReturnsCorrectCount() + public function testGetTotalCountReturnsCorrectCount(): void { SearchableModel::create(['title' => 'First', 'body' => 'Body']); SearchableModel::create(['title' => 'Second', 'body' => 'Body']); diff --git a/tests/Scout/Feature/CoroutineSafetyTest.php b/tests/Scout/Feature/CoroutineSafetyTest.php index 57b46ea13..f008a2081 100644 --- a/tests/Scout/Feature/CoroutineSafetyTest.php +++ b/tests/Scout/Feature/CoroutineSafetyTest.php @@ -29,7 +29,7 @@ */ class CoroutineSafetyTest extends ScoutTestCase { - public function testDisableSyncingIsCoroutineIsolated() + public function testDisableSyncingIsCoroutineIsolated(): void { // Initially syncing is enabled $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); @@ -65,7 +65,7 @@ public function testDisableSyncingIsCoroutineIsolated() $this->assertTrue($results['coroutine2']); } - public function testWithoutSyncingToSearchIsCoroutineIsolated() + public function testWithoutSyncingToSearchIsCoroutineIsolated(): void { $results = []; $waiter = new WaitGroup; @@ -103,7 +103,7 @@ public function testWithoutSyncingToSearchIsCoroutineIsolated() $this->assertTrue($results['concurrent']); } - public function testMultipleConcurrentDisableSync() + public function testMultipleConcurrentDisableSync(): void { $results = []; $waiter = new WaitGroup; @@ -155,7 +155,7 @@ public function testMultipleConcurrentDisableSync() } } - public function testNestedCoroutinesHaveIsolatedContext() + public function testNestedCoroutinesHaveIsolatedContext(): void { $results = []; $waiter = new WaitGroup; @@ -192,7 +192,7 @@ public function testNestedCoroutinesHaveIsolatedContext() $this->assertTrue($results['child']); } - public function testWhileImportingIsCoroutineIsolated() + public function testWhileImportingIsCoroutineIsolated(): void { $results = []; $waiter = new WaitGroup; @@ -224,7 +224,7 @@ public function testWhileImportingIsCoroutineIsolated() $this->assertFalse($results['coroutine2']); } - public function testWhileImportingRestoresStateAfterCallbackAndOnException() + public function testWhileImportingRestoresStateAfterCallbackAndOnException(): void { // Normal-return path $beforeNormal = Scout::isImporting(); @@ -256,7 +256,7 @@ public function testWhileImportingRestoresStateAfterCallbackAndOnException() $this->assertFalse($afterException); } - public function testImportProgressReportingIsCoroutineIsolated() + public function testImportProgressReportingIsCoroutineIsolated(): void { $results = []; $waiter = new WaitGroup; @@ -303,7 +303,7 @@ function (): void { $this->assertSame(['B'], $results['b']); } - public function testQueueMakeSearchableBypassIsCoroutineIsolated() + public function testQueueMakeSearchableBypassIsCoroutineIsolated(): void { $this->app->make('config')->set('scout.queue.enabled', true); @@ -339,7 +339,7 @@ public function testQueueMakeSearchableBypassIsCoroutineIsolated() Bus::assertDispatchedTimes(MakeSearchable::class, 1); } - public function testQueueRemoveFromSearchBypassIsCoroutineIsolated() + public function testQueueRemoveFromSearchBypassIsCoroutineIsolated(): void { $this->app->make('config')->set('scout.queue.enabled', true); @@ -374,7 +374,7 @@ public function testQueueRemoveFromSearchBypassIsCoroutineIsolated() Bus::assertDispatchedTimes(RemoveFromSearch::class, 1); } - public function testRunnerStateIsCoroutineIsolated() + public function testRunnerStateIsCoroutineIsolated(): void { // queue.enabled=false so dispatch goes through dispatchSearchableJob (which sets up the runner) $this->app->make('config')->set('scout.queue.enabled', false); @@ -411,7 +411,7 @@ public function testRunnerStateIsCoroutineIsolated() $this->assertFalse($results['b_has_runner']); } - public function testModelObserverForceSavingIsCoroutineIsolated() + public function testModelObserverForceSavingIsCoroutineIsolated(): void { $observer = new ModelObserver; $forcingModel = new ForceSavingSearchableModel; @@ -448,7 +448,7 @@ public function testModelObserverForceSavingIsCoroutineIsolated() $this->assertSame(1, $results['blocked_update_checks']); } - public function testModelObserverRestoredForcesSearchableUpdate() + public function testModelObserverRestoredForcesSearchableUpdate(): void { $observer = new ModelObserver; $model = new ForceSavingSearchableModel; @@ -459,7 +459,7 @@ public function testModelObserverRestoredForcesSearchableUpdate() $this->assertSame(0, $model->searchIndexUpdateChecks); } - public function testWhileImportingIsNestingSafe() + public function testWhileImportingIsNestingSafe(): void { $results = []; diff --git a/tests/Scout/Feature/DatabaseEngineTest.php b/tests/Scout/Feature/DatabaseEngineTest.php index bba1d9fd8..788a8a30b 100644 --- a/tests/Scout/Feature/DatabaseEngineTest.php +++ b/tests/Scout/Feature/DatabaseEngineTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Scout\Feature; +use ArgumentCountError; use Hypervel\Scout\Attributes\SearchUsingFullText; use Hypervel\Scout\Attributes\SearchUsingPrefix; use Hypervel\Scout\Engines\DatabaseEngine; @@ -58,6 +59,21 @@ public function testSearchWithWhereClause(): void $this->assertEquals($model1->id, $results->first()->id); } + public function testSearchWithMultipleComparisonsOnTheSameField(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + $second = SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + $third = SearchableModel::create(['title' => 'Third', 'body' => 'Body']); + SearchableModel::create(['title' => 'Fourth', 'body' => 'Body']); + + $results = SearchableModel::search('') + ->where('id', '>=', $second->id) + ->where('id', '<=', $third->id) + ->get(); + + $this->assertSame([$third->id, $second->id], $results->pluck('id')->all()); + } + public function testSearchWithWhereInClause(): void { $model1 = SearchableModel::create(['title' => 'Test A', 'body' => 'Body']); @@ -197,6 +213,32 @@ public function testSearchAttributeMetadataIsCachedPerEngineInstance(): void $this->assertSame([], $invoker->fullTextOptions[SearchableModel::class]); } + public function testNamedSearchAttributeArgumentsUseTheAttributeContract(): void + { + $engine = new DatabaseEngine; + $invoker = new ClassInvoker($engine); + $builder = NamedSearchAttributesModel::search('hello'); + + $this->assertSame(['title'], $invoker->getPrefixColumns($builder)); + $this->assertSame(['body'], $invoker->getFullTextColumns($builder)); + $this->assertSame( + ['mode' => 'websearch', 'language' => 'english'], + $invoker->getFullTextOptions($builder) + ); + + $this->assertSame(['title'], $invoker->getPrefixColumns($builder)); + $this->assertSame(['body'], $invoker->getFullTextColumns($builder)); + } + + public function testMalformedSearchAttributeFailsFast(): void + { + $this->expectException(ArgumentCountError::class); + + (new ClassInvoker(new DatabaseEngine))->getPrefixColumns( + MalformedSearchAttributeModel::search('hello') + ); + } + public function testCreateAndDeleteIndexAreNoOps(): void { $engine = new DatabaseEngine; @@ -343,3 +385,22 @@ public function testRegularModelUsesFullWildcardOnTitle(): void $this->assertCount(2, $results); } } + +class NamedSearchAttributesModel extends SearchableModel +{ + #[SearchUsingPrefix(columns: ['title'])] + #[SearchUsingFullText(columns: ['body'], options: ['mode' => 'websearch', 'language' => 'english'])] + public function toSearchableArray(): array + { + return parent::toSearchableArray(); + } +} + +class MalformedSearchAttributeModel extends SearchableModel +{ + #[SearchUsingPrefix] + public function toSearchableArray(): array + { + return parent::toSearchableArray(); + } +} diff --git a/tests/Scout/Feature/SearchableModelTest.php b/tests/Scout/Feature/SearchableModelTest.php index 3a6313b9b..8b3d8fb28 100644 --- a/tests/Scout/Feature/SearchableModelTest.php +++ b/tests/Scout/Feature/SearchableModelTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Scout\Feature; use Hypervel\Database\Eloquent\Model; +use Hypervel\Scout\Builder; use Hypervel\Support\Collection as BaseCollection; use Hypervel\Tests\Scout\Models\SearchableModel; use Hypervel\Tests\Scout\Models\SoftDeletableSearchableModel; @@ -29,7 +30,7 @@ public function testSearchableBootDefersModelInstanceWorkUntilAfterPublication() )); } - public function testSearchReturnsBuilder() + public function testSearchReturnsBuilder(): void { $builder = SearchableModel::search('test'); @@ -37,14 +38,26 @@ public function testSearchReturnsBuilder() $this->assertSame('test', $builder->query); } - public function testSearchableAsReturnsTableName() + public function testSearchUsesModelSelectedBuilder(): void + { + $this->assertInstanceOf(CustomScoutBuilder::class, CustomScoutBuilderModel::search('test')); + } + + public function testSearchHonorsBuilderContainerSubstitution(): void + { + $this->app->bind(Builder::class, ContainerScoutBuilder::class); + + $this->assertInstanceOf(ContainerScoutBuilder::class, SearchableModel::search('test')); + } + + public function testSearchableAsReturnsTableName(): void { $model = new SearchableModel; $this->assertSame('searchable_models', $model->searchableAs()); } - public function testSearchableAsReturnsTableNameWithPrefix() + public function testSearchableAsReturnsTableNameWithPrefix(): void { // Set a prefix in the config $this->app->make('config') @@ -55,7 +68,7 @@ public function testSearchableAsReturnsTableNameWithPrefix() $this->assertSame('test_searchable_models', $model->searchableAs()); } - public function testToSearchableArrayReturnsModelArray() + public function testToSearchableArrayReturnsModelArray(): void { $model = SearchableModel::create([ 'title' => 'Test Title', @@ -70,7 +83,7 @@ public function testToSearchableArrayReturnsModelArray() $this->assertSame('Test Title', $searchable['title']); } - public function testGetScoutKeyReturnsModelKey() + public function testGetScoutKeyReturnsModelKey(): void { $model = SearchableModel::create([ 'title' => 'Test Title', @@ -104,21 +117,21 @@ public function testUnsavedModelRetainsNullableScoutKeyIntrospection(): void $this->assertNull((new SearchableModel)->getScoutKey()); } - public function testGetScoutKeyNameReturnsModelKeyName() + public function testGetScoutKeyNameReturnsModelKeyName(): void { $model = new SearchableModel; $this->assertSame('id', $model->getScoutKeyName()); } - public function testShouldBeSearchableReturnsTrueByDefault() + public function testShouldBeSearchableReturnsTrueByDefault(): void { $model = new SearchableModel; $this->assertTrue($model->shouldBeSearchable()); } - public function testDisableSearchSyncingPreventsIndexing() + public function testDisableSearchSyncingPreventsIndexing(): void { // Initially syncing is enabled $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); @@ -134,7 +147,7 @@ public function testDisableSearchSyncingPreventsIndexing() $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); } - public function testWithoutSyncingToSearchExecutesCallbackAndRestoresState() + public function testWithoutSyncingToSearchExecutesCallbackAndRestoresState(): void { $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); @@ -149,7 +162,7 @@ public function testWithoutSyncingToSearchExecutesCallbackAndRestoresState() $this->assertSame('callback result', $result); } - public function testWithoutSyncingToSearchRestoresStateOnException() + public function testWithoutSyncingToSearchRestoresStateOnException(): void { $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); @@ -165,14 +178,44 @@ public function testWithoutSyncingToSearchRestoresStateOnException() $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); } - public function testMakeAllSearchableQueryReturnsBuilder() + public function testWithoutSyncingToSearchPreservesOuterDisabledScope(): void + { + SearchableModel::withoutSyncingToSearch(function (): void { + $this->assertFalse(SearchableModel::isSearchSyncingEnabled()); + + SearchableModel::withoutSyncingToSearch(function (): void { + $this->assertFalse(SearchableModel::isSearchSyncingEnabled()); + }); + + $this->assertFalse(SearchableModel::isSearchSyncingEnabled()); + }); + + $this->assertTrue(SearchableModel::isSearchSyncingEnabled()); + } + + public function testWithoutSyncingToSearchRestoresPreexistingDisabledState(): void + { + SearchableModel::disableSearchSyncing(); + + try { + SearchableModel::withoutSyncingToSearch(function (): void { + $this->assertFalse(SearchableModel::isSearchSyncingEnabled()); + }); + + $this->assertFalse(SearchableModel::isSearchSyncingEnabled()); + } finally { + SearchableModel::enableSearchSyncing(); + } + } + + public function testMakeAllSearchableQueryReturnsBuilder(): void { $query = SearchableModel::makeAllSearchableQuery(); $this->assertInstanceOf(\Hypervel\Database\Eloquent\Builder::class, $query); } - public function testScoutMetadataCanBeSetAndRetrieved() + public function testScoutMetadataCanBeSetAndRetrieved(): void { $model = new SearchableModel; @@ -185,7 +228,7 @@ public function testScoutMetadataCanBeSetAndRetrieved() $this->assertSame(['title' => 'test'], $metadata['_highlight']); } - public function testModelCanBeSearched() + public function testModelCanBeSearched(): void { // Create some models SearchableModel::create(['title' => 'First Post', 'body' => 'Content']); @@ -198,7 +241,7 @@ public function testModelCanBeSearched() $this->assertCount(2, $results); } - public function testSoftDeletedModelsAreExcludedByDefault() + public function testSoftDeletedModelsAreExcludedByDefault(): void { // Set soft delete config $this->app->make('config') @@ -218,7 +261,7 @@ public function testSoftDeletedModelsAreExcludedByDefault() $this->assertCount(0, $results); } - public function testSoftDeletedModelsCanBeIncludedWithWithTrashed() + public function testSoftDeletedModelsCanBeIncludedWithWithTrashed(): void { // Set soft delete config $this->app->make('config') @@ -241,42 +284,42 @@ public function testSoftDeletedModelsCanBeIncludedWithWithTrashed() $this->assertCount(1, $results); } - public function testSearchIndexShouldBeUpdatedReturnsTrueByDefault() + public function testSearchIndexShouldBeUpdatedReturnsTrueByDefault(): void { $model = new SearchableModel; $this->assertTrue($model->searchIndexShouldBeUpdated()); } - public function testWasSearchableBeforeUpdateReturnsTrueByDefault() + public function testWasSearchableBeforeUpdateReturnsTrueByDefault(): void { $model = new SearchableModel; $this->assertTrue($model->wasSearchableBeforeUpdate()); } - public function testWasSearchableBeforeDeleteReturnsTrueByDefault() + public function testWasSearchableBeforeDeleteReturnsTrueByDefault(): void { $model = new SearchableModel; $this->assertTrue($model->wasSearchableBeforeDelete()); } - public function testIndexableAsReturnsSearchableAsByDefault() + public function testIndexableAsReturnsSearchableAsByDefault(): void { $model = new SearchableModel; $this->assertSame($model->searchableAs(), $model->indexableAs()); } - public function testGetScoutKeyTypeReturnsModelKeyType() + public function testGetScoutKeyTypeReturnsModelKeyType(): void { $model = new SearchableModel; $this->assertSame('int', $model->getScoutKeyType()); } - public function testMakeSearchableUsingReturnsModelsUnchangedByDefault() + public function testMakeSearchableUsingReturnsModelsUnchangedByDefault(): void { $model = new SearchableModel; $collection = $model->newCollection([new SearchableModel, new SearchableModel]); @@ -286,3 +329,16 @@ public function testMakeSearchableUsingReturnsModelsUnchangedByDefault() $this->assertSame($collection, $result); } } + +class CustomScoutBuilderModel extends SearchableModel +{ + protected static string $scoutBuilder = CustomScoutBuilder::class; +} + +class CustomScoutBuilder extends Builder +{ +} + +class ContainerScoutBuilder extends Builder +{ +} diff --git a/tests/Scout/Feature/SearchableScopeTest.php b/tests/Scout/Feature/SearchableScopeTest.php index f3361c42a..84b598a60 100644 --- a/tests/Scout/Feature/SearchableScopeTest.php +++ b/tests/Scout/Feature/SearchableScopeTest.php @@ -4,8 +4,14 @@ namespace Hypervel\Tests\Scout\Feature; +use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; +use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Events\ModelsFlushed; use Hypervel\Scout\Events\ModelsImported; +use Hypervel\Scout\Scout; +use Hypervel\Scout\Searchable; use Hypervel\Support\Facades\Event; use Hypervel\Tests\Scout\Models\ConditionalSearchableModel; use Hypervel\Tests\Scout\Models\SearchableModel; @@ -101,6 +107,26 @@ public function testUnsearchableMacroRespectsCustomChunkSize(): void Event::assertDispatched(ModelsFlushed::class, 3); } + public function testSearchableMacrosUseConfiguredChunkSizesByDefault(): void + { + for ($i = 1; $i <= 5; ++$i) { + SearchableModel::create(['title' => "Item {$i}", 'body' => 'Body']); + } + + config([ + 'scout.chunk.searchable' => 2, + 'scout.chunk.unsearchable' => 3, + ]); + + Event::fake([ModelsImported::class, ModelsFlushed::class]); + + SearchableModel::query()->searchable(); + SearchableModel::query()->unsearchable(); + + Event::assertDispatched(ModelsImported::class, 3); + Event::assertDispatched(ModelsFlushed::class, 2); + } + public function testSearchableMacroWorksWithQueryConstraints(): void { SearchableModel::create(['title' => 'Include This', 'body' => 'Body']); @@ -118,4 +144,103 @@ public function testSearchableMacroWorksWithQueryConstraints(): void && $event->models->first()->title === 'Include This'; }); } + + public function testSearchableMacroReportsProgressWithoutEventListeners(): void + { + SearchableModel::create(['title' => 'First', 'body' => 'Body']); + SearchableModel::create(['title' => 'Second', 'body' => 'Body']); + + $reported = 0; + + Scout::whileReportingImportProgress( + function (Collection $models) use (&$reported): void { + $reported += $models->count(); + }, + fn () => SearchableModel::query()->searchable(), + ); + + $this->assertSame(2, $reported); + } + + public function testHasManyThroughMacrosUseTheRelationChunkQuery(): void + { + $owner = ScoutThroughOwner::createQuietly(); + $intermediate = ScoutThroughIntermediate::createQuietly(['owner_id' => $owner->getKey()]); + + $modelIds = []; + + for ($i = 1; $i <= 3; ++$i) { + $modelIds[] = ScoutThroughSearchableModel::createQuietly([ + 'intermediate_id' => $intermediate->getKey(), + 'title' => "Model {$i}", + ])->getKey(); + } + + config([ + 'scout.chunk.searchable' => 2, + 'scout.chunk.unsearchable' => 2, + ]); + + Event::fake([ModelsImported::class, ModelsFlushed::class]); + + $owner->searchableModels()->searchable(); + + Event::assertDispatched(ModelsImported::class, 2); + $importedIds = Event::dispatched(ModelsImported::class) + ->flatMap(fn (array $arguments) => $arguments[0]->models->modelKeys()) + ->sort() + ->values() + ->all(); + + $this->assertSame($modelIds, $importedIds); + + $owner->searchableModels()->unsearchable(); + + Event::assertDispatched(ModelsFlushed::class, 2); + $flushedIds = Event::dispatched(ModelsFlushed::class) + ->flatMap(fn (array $arguments) => $arguments[0]->models->modelKeys()) + ->sort() + ->values() + ->all(); + + $this->assertSame($modelIds, $flushedIds); + } +} + +class ScoutThroughOwner extends Model +{ + protected ?string $table = 'scout_through_owners'; + + protected array $guarded = []; + + /** + * Get the searchable models through the intermediate records. + * + * @return HasManyThrough + */ + public function searchableModels(): HasManyThrough + { + return $this->hasManyThrough( + ScoutThroughSearchableModel::class, + ScoutThroughIntermediate::class, + 'owner_id', + 'intermediate_id', + ); + } +} + +class ScoutThroughIntermediate extends Model +{ + protected ?string $table = 'scout_through_intermediates'; + + protected array $guarded = []; +} + +class ScoutThroughSearchableModel extends Model implements SearchableInterface +{ + use Searchable; + + protected ?string $table = 'scout_through_models'; + + protected array $guarded = []; } diff --git a/tests/Scout/Models/CustomScoutKeyModel.php b/tests/Scout/Models/CustomScoutKeyModel.php index 8041f28cb..791099c08 100644 --- a/tests/Scout/Models/CustomScoutKeyModel.php +++ b/tests/Scout/Models/CustomScoutKeyModel.php @@ -24,7 +24,7 @@ class CustomScoutKeyModel extends Model implements SearchableInterface */ public function getScoutKey(): mixed { - return 'custom-key.' . $this->id; + return 'custom-key-' . $this->id; } public function getScoutKeyName(): string diff --git a/tests/Scout/Models/TypesenseSearchableModel.php b/tests/Scout/Models/TypesenseSearchableModel.php index 741b616ea..e1fa3b79b 100644 --- a/tests/Scout/Models/TypesenseSearchableModel.php +++ b/tests/Scout/Models/TypesenseSearchableModel.php @@ -25,6 +25,7 @@ public function toSearchableArray(): array { return [ 'id' => (string) $this->id, + 'ranking' => (int) $this->id, 'title' => $this->title, 'body' => $this->body ?? '', ]; @@ -41,6 +42,7 @@ public function typesenseCollectionSchema(): array 'name' => $this->searchableAs(), 'fields' => [ ['name' => 'id', 'type' => 'string', 'facet' => true], + ['name' => 'ranking', 'type' => 'int32', 'facet' => true], ['name' => 'title', 'type' => 'string', 'facet' => true, 'sort' => true], ['name' => 'body', 'type' => 'string', 'facet' => true], ], diff --git a/tests/Scout/PackageMetadataTest.php b/tests/Scout/PackageMetadataTest.php new file mode 100644 index 000000000..2232d4cbe --- /dev/null +++ b/tests/Scout/PackageMetadataTest.php @@ -0,0 +1,62 @@ +assertArrayHasKey($dependency, $composer['require']); + $this->assertIsString($composer['require'][$dependency]); + $this->assertNotSame('', trim($composer['require'][$dependency])); + } + + $this->assertSame('^7.15.1', $composer['require']['guzzlehttp/guzzle']); + $this->assertSame('^2.0', $composer['require']['psr/http-message']); + + foreach ([ + 'algolia/algoliasearch-client-php', + 'meilisearch/meilisearch-php', + 'typesense/typesense-php', + ] as $dependency) { + $this->assertArrayHasKey($dependency, $composer['suggest']); + $this->assertArrayNotHasKey($dependency, $composer['require']); + } + } +} diff --git a/tests/Scout/ScoutTestCase.php b/tests/Scout/ScoutTestCase.php index 819d4e776..b4fb4be40 100644 --- a/tests/Scout/ScoutTestCase.php +++ b/tests/Scout/ScoutTestCase.php @@ -37,8 +37,8 @@ protected function setUp(): void 'enabled' => false, 'connection' => null, 'queue' => null, - 'after_commit' => false, ], + 'after_commit' => false, 'soft_delete' => false, 'chunk' => [ 'searchable' => 500, diff --git a/tests/Scout/Unit/BuilderTest.php b/tests/Scout/Unit/BuilderTest.php index f5b0e6da2..9e234cc4e 100644 --- a/tests/Scout/Unit/BuilderTest.php +++ b/tests/Scout/Unit/BuilderTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Scout\Unit; +use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Pagination\LengthAwarePaginator; @@ -18,7 +19,7 @@ class BuilderTest extends TestCase { - public function testBuilderStoresQueryAndModel() + public function testBuilderStoresQueryAndModel(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'test query'); @@ -27,7 +28,7 @@ public function testBuilderStoresQueryAndModel() $this->assertSame('test query', $builder->query); } - public function testWhereAddsConstraint() + public function testWhereAddsConstraint(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -35,10 +36,30 @@ public function testWhereAddsConstraint() $result = $builder->where('status', 'active'); $this->assertSame($builder, $result); - $this->assertSame(['status' => 'active'], $builder->wheres); + $this->assertSame([[ + 'field' => 'status', + 'operator' => '=', + 'value' => 'active', + ]], $builder->wheres); } - public function testWhereInAddsConstraint() + public function testWhereAddsComparisonConstraintWithoutOverwritingTheSameField(): void + { + $model = m::mock(Model::class); + $builder = new Builder($model, 'query'); + + $result = $builder + ->where('price', '>=', 10) + ->where('price', '<', 20); + + $this->assertSame($builder, $result); + $this->assertSame([ + ['field' => 'price', 'operator' => '>=', 'value' => 10], + ['field' => 'price', 'operator' => '<', 'value' => 20], + ], $builder->wheres); + } + + public function testWhereInAddsConstraint(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -49,7 +70,7 @@ public function testWhereInAddsConstraint() $this->assertSame(['id' => [1, 2, 3]], $builder->whereIns); } - public function testWhereInAcceptsArrayable() + public function testWhereInAcceptsArrayable(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -61,7 +82,7 @@ public function testWhereInAcceptsArrayable() $this->assertSame(['id' => [1, 2, 3]], $builder->whereIns); } - public function testWhereNotInAddsConstraint() + public function testWhereNotInAddsConstraint(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -72,7 +93,7 @@ public function testWhereNotInAddsConstraint() $this->assertSame(['id' => [4, 5, 6]], $builder->whereNotIns); } - public function testWithinSetsCustomIndex() + public function testWithinSetsCustomIndex(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -83,7 +104,7 @@ public function testWithinSetsCustomIndex() $this->assertSame('custom_index', $builder->index); } - public function testTakeSetsLimit() + public function testTakeSetsLimit(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -94,7 +115,7 @@ public function testTakeSetsLimit() $this->assertSame(100, $builder->limit); } - public function testOrderByAddsOrder() + public function testOrderByAddsOrder(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -105,7 +126,7 @@ public function testOrderByAddsOrder() $this->assertSame([['column' => 'name', 'direction' => 'asc']], $builder->orders); } - public function testOrderByNormalizesDirection() + public function testOrderByNormalizesDirection(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -115,7 +136,7 @@ public function testOrderByNormalizesDirection() $this->assertSame([['column' => 'name', 'direction' => 'asc']], $builder->orders); } - public function testOrderByDescAddsDescendingOrder() + public function testOrderByDescAddsDescendingOrder(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -126,7 +147,7 @@ public function testOrderByDescAddsDescendingOrder() $this->assertSame([['column' => 'name', 'direction' => 'desc']], $builder->orders); } - public function testLatestOrdersByCreatedAtDesc() + public function testLatestOrdersByCreatedAtDesc(): void { $model = m::mock(Model::class); $model->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); @@ -138,7 +159,7 @@ public function testLatestOrdersByCreatedAtDesc() $this->assertSame([['column' => 'created_at', 'direction' => 'desc']], $builder->orders); } - public function testLatestWithCustomColumn() + public function testLatestWithCustomColumn(): void { $model = m::mock(Model::class); @@ -149,7 +170,7 @@ public function testLatestWithCustomColumn() $this->assertSame([['column' => 'updated_at', 'direction' => 'desc']], $builder->orders); } - public function testOldestOrdersByCreatedAtAsc() + public function testOldestOrdersByCreatedAtAsc(): void { $model = m::mock(Model::class); $model->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); @@ -161,7 +182,7 @@ public function testOldestOrdersByCreatedAtAsc() $this->assertSame([['column' => 'created_at', 'direction' => 'asc']], $builder->orders); } - public function testOptionsSetsOptions() + public function testOptionsSetsOptions(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -172,7 +193,7 @@ public function testOptionsSetsOptions() $this->assertSame(['highlight' => true], $builder->options); } - public function testQuerySetsQueryCallback() + public function testQuerySetsQueryCallback(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -184,7 +205,7 @@ public function testQuerySetsQueryCallback() $this->assertNotNull($builder->queryCallback); } - public function testWithRawResultsSetsCallback() + public function testWithRawResultsSetsCallback(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -196,36 +217,40 @@ public function testWithRawResultsSetsCallback() $this->assertNotNull($builder->afterRawSearchCallback); } - public function testSoftDeleteSetsSoftDeleteWhere() + public function testSoftDeleteSetsSoftDeleteWhere(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query', null, softDelete: true); - $this->assertSame(0, $builder->wheres['__soft_deleted']); + $this->assertSame([[ + 'field' => '__soft_deleted', + 'operator' => '=', + 'value' => 0, + ]], $builder->wheres); } - public function testHardDeleteDoesNotSetSoftDeleteWhere() + public function testHardDeleteDoesNotSetSoftDeleteWhere(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query', null, softDelete: false); - $this->assertArrayNotHasKey('__soft_deleted', $builder->wheres); + $this->assertSame([], $builder->wheres); } - public function testWithTrashedRemovesSoftDeleteWhere() + public function testWithTrashedRemovesSoftDeleteWhere(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query', null, softDelete: true); - $this->assertSame(0, $builder->wheres['__soft_deleted']); + $this->assertSame('__soft_deleted', $builder->wheres[0]['field']); $result = $builder->withTrashed(); $this->assertSame($builder, $result); - $this->assertArrayNotHasKey('__soft_deleted', $builder->wheres); + $this->assertSame([], $builder->wheres); } - public function testOnlyTrashedSetsSoftDeleteWhereToOne() + public function testOnlyTrashedSetsSoftDeleteWhereToOne(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query', null, softDelete: true); @@ -233,10 +258,14 @@ public function testOnlyTrashedSetsSoftDeleteWhereToOne() $result = $builder->onlyTrashed(); $this->assertSame($builder, $result); - $this->assertSame(1, $builder->wheres['__soft_deleted']); + $this->assertSame([[ + 'field' => '__soft_deleted', + 'operator' => '=', + 'value' => 1, + ]], $builder->wheres); } - public function testRawCallsEngineSearch() + public function testRawCallsEngineSearch(): void { $model = m::mock(Model::class); $engine = m::mock(Engine::class); @@ -253,7 +282,7 @@ public function testRawCallsEngineSearch() $this->assertEquals(['hits' => [], 'totalHits' => 0], $result); } - public function testKeysCallsEngineKeys() + public function testKeysCallsEngineKeys(): void { $model = m::mock(Model::class); $engine = m::mock(Engine::class); @@ -270,7 +299,7 @@ public function testKeysCallsEngineKeys() $this->assertEquals([1, 2, 3], $result->all()); } - public function testGetCallsEngineGet() + public function testGetCallsEngineGet(): void { $model = m::mock(Model::class); $engine = m::mock(Engine::class); @@ -288,7 +317,7 @@ public function testGetCallsEngineGet() $this->assertCount(1, $result); } - public function testFirstReturnsFirstResult() + public function testFirstReturnsFirstResult(): void { $model = m::mock(Model::class); $engine = m::mock(Engine::class); @@ -307,7 +336,7 @@ public function testFirstReturnsFirstResult() $this->assertSame($firstModel, $result); } - public function testFirstReturnsNullWhenNoResults() + public function testFirstReturnsNullWhenNoResults(): void { $model = m::mock(Model::class); $engine = m::mock(Engine::class); @@ -324,7 +353,7 @@ public function testFirstReturnsNullWhenNoResults() $this->assertNull($result); } - public function testPaginationCorrectlyHandlesPaginatedResults() + public function testPaginationCorrectlyHandlesPaginatedResults(): void { Paginator::currentPageResolver(function () { return 1; @@ -362,7 +391,7 @@ public function testPaginationCorrectlyHandlesPaginatedResults() $this->assertSame(1, $paginated->currentPage()); } - public function testSimplePaginationCorrectlyHandlesPaginatedResults() + public function testSimplePaginationCorrectlyHandlesPaginatedResults(): void { Paginator::currentPageResolver(function () { return 1; @@ -399,7 +428,7 @@ public function testSimplePaginationCorrectlyHandlesPaginatedResults() $this->assertSame(1, $paginated->currentPage()); } - public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentModels() + public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentModels(): void { Paginator::currentPageResolver(fn () => 1); Paginator::currentPathResolver(fn () => 'http://localhost/foo'); @@ -425,7 +454,7 @@ public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentMode $this->assertInstanceOf(LengthAwarePaginator::class, $result); } - public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloquentModels() + public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloquentModels(): void { Paginator::currentPageResolver(fn () => 1); Paginator::currentPathResolver(fn () => 'http://localhost/foo'); @@ -451,7 +480,7 @@ public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloque $this->assertInstanceOf(Paginator::class, $result); } - public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentModelsUsingDatabase() + public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentModelsUsingDatabase(): void { Paginator::currentPageResolver(fn () => 1); Paginator::currentPathResolver(fn () => 'http://localhost/foo'); @@ -477,7 +506,7 @@ public function testPaginateDelegatesToEngineWhenImplementsPaginatesEloquentMode $this->assertInstanceOf(LengthAwarePaginator::class, $result); } - public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloquentModelsUsingDatabase() + public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloquentModelsUsingDatabase(): void { Paginator::currentPageResolver(fn () => 1); Paginator::currentPathResolver(fn () => 'http://localhost/foo'); @@ -503,7 +532,100 @@ public function testSimplePaginateDelegatesToEngineWhenImplementsPaginatesEloque $this->assertInstanceOf(Paginator::class, $result); } - public function testMacroable() + public function testRawPaginationDelegatesToEngineWhenItPaginatesEloquentModels(): void + { + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->twice()->andReturn(15); + + $engine = m::mock(Engine::class . ', ' . PaginatesEloquentModels::class); + $model->shouldReceive('searchableUsing')->twice()->andReturn($engine); + + $expectedPaginator = new LengthAwarePaginator([], 0, 15, 1); + $expectedSimplePaginator = new Paginator([], 15, 1); + + $engine->shouldReceive('paginate') + ->once() + ->with(m::type(Builder::class), 15, 1) + ->andReturn($expectedPaginator); + $engine->shouldReceive('simplePaginate') + ->once() + ->with(m::type(Builder::class), 15, 1) + ->andReturn($expectedSimplePaginator); + + $builder = new Builder($model, 'test query'); + + $this->assertSame($expectedPaginator, $builder->paginateRaw(page: 1)); + $this->assertSame($expectedSimplePaginator, $builder->simplePaginateRaw(page: 1)); + } + + public function testRawPaginationDelegatesToEngineWhenItPaginatesUsingDatabase(): void + { + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->twice()->andReturn(15); + + $engine = m::mock(Engine::class . ', ' . PaginatesEloquentModelsUsingDatabase::class); + $model->shouldReceive('searchableUsing')->twice()->andReturn($engine); + + $expectedPaginator = new LengthAwarePaginator([], 0, 15, 1); + $expectedSimplePaginator = new Paginator([], 15, 1); + + $engine->shouldReceive('paginateUsingDatabase') + ->once() + ->with(m::type(Builder::class), 15, 'results', 1) + ->andReturn($expectedPaginator); + $engine->shouldReceive('simplePaginateUsingDatabase') + ->once() + ->with(m::type(Builder::class), 15, 'results', 1) + ->andReturn($expectedSimplePaginator); + + $builder = new Builder($model, 'test query'); + + $this->assertSame($expectedPaginator, $builder->paginateRaw(pageName: 'results', page: 1)); + $this->assertSame($expectedSimplePaginator, $builder->simplePaginateRaw(pageName: 'results', page: 1)); + } + + public function testGenericPaginationUsesFreshContainerSubstitutionsAndDefaultPerPageForZero(): void + { + Paginator::currentPageResolver(fn () => 1); + Paginator::currentPathResolver(fn () => 'http://localhost/foo'); + + Container::getInstance()->bind(Paginator::class, ScoutBuilderPaginator::class); + Container::getInstance()->bind(LengthAwarePaginator::class, ScoutBuilderLengthAwarePaginator::class); + + $model = m::mock(Model::class); + $model->shouldReceive('getPerPage')->times(4)->andReturn(15); + $model->shouldReceive('searchableUsing')->times(6)->andReturn($engine = m::mock(Engine::class)); + $model->shouldReceive('newCollection')->twice()->andReturnUsing( + fn (array $models) => new EloquentCollection($models) + ); + + $rawResults = ['hits' => [], 'estimatedTotalHits' => 0]; + + $engine->shouldReceive('paginate')->times(4)->andReturn($rawResults); + $engine->shouldReceive('map')->twice()->andReturn(new EloquentCollection); + $engine->shouldReceive('getTotalCount')->times(4)->andReturn(0); + + $builder = new Builder($model, 'zonda'); + + $simple = $builder->simplePaginate(0); + $simpleRaw = $builder->simplePaginateRaw(0); + $lengthAware = $builder->paginate(0); + $lengthAwareRaw = $builder->paginateRaw(0); + + $this->assertInstanceOf(ScoutBuilderPaginator::class, $simple); + $this->assertInstanceOf(ScoutBuilderPaginator::class, $simpleRaw); + $this->assertNotSame($simple, $simpleRaw); + $this->assertSame(15, $simple->perPage()); + $this->assertSame(15, $simpleRaw->perPage()); + + $this->assertInstanceOf(ScoutBuilderLengthAwarePaginator::class, $lengthAware); + $this->assertInstanceOf(ScoutBuilderLengthAwarePaginator::class, $lengthAwareRaw); + $this->assertNotSame($lengthAware, $lengthAwareRaw); + $this->assertSame(15, $lengthAware->perPage()); + $this->assertSame(15, $lengthAwareRaw->perPage()); + } + + public function testMacroable(): void { Builder::macro('testMacro', function () { return 'macro result'; @@ -515,7 +637,7 @@ public function testMacroable() $this->assertSame('macro result', $builder->testMacro()); } - public function testApplyAfterRawSearchCallbackInvokesCallback() + public function testApplyAfterRawSearchCallbackInvokesCallback(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -530,7 +652,7 @@ public function testApplyAfterRawSearchCallbackInvokesCallback() $this->assertTrue($result['modified']); } - public function testApplyAfterRawSearchCallbackReturnsOriginalWhenNoCallback() + public function testApplyAfterRawSearchCallbackReturnsOriginalWhenNoCallback(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -541,7 +663,7 @@ public function testApplyAfterRawSearchCallbackReturnsOriginalWhenNoCallback() $this->assertSame($original, $result); } - public function testSimplePaginateRawCorrectlyHandlesPaginatedResults() + public function testSimplePaginateRawCorrectlyHandlesPaginatedResults(): void { Paginator::currentPageResolver(function () { return 1; @@ -568,7 +690,7 @@ public function testSimplePaginateRawCorrectlyHandlesPaginatedResults() $this->assertSame(1, $paginated->currentPage()); } - public function testWhereInAcceptsArrayableInterface() + public function testWhereInAcceptsArrayableInterface(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -582,7 +704,7 @@ public function testWhereInAcceptsArrayableInterface() $this->assertSame(['id' => [1, 2, 3]], $builder->whereIns); } - public function testWhereNotInAcceptsArrayableInterface() + public function testWhereNotInAcceptsArrayableInterface(): void { $model = m::mock(Model::class); $builder = new Builder($model, 'query'); @@ -596,3 +718,11 @@ public function testWhereNotInAcceptsArrayableInterface() $this->assertSame(['id' => [4, 5, 6]], $builder->whereNotIns); } } + +class ScoutBuilderPaginator extends Paginator +{ +} + +class ScoutBuilderLengthAwarePaginator extends LengthAwarePaginator +{ +} diff --git a/tests/Scout/Unit/ConfigFileTest.php b/tests/Scout/Unit/ConfigFileTest.php index 9ae8ba49e..03a4ccf3a 100644 --- a/tests/Scout/Unit/ConfigFileTest.php +++ b/tests/Scout/Unit/ConfigFileTest.php @@ -6,6 +6,7 @@ use Hypervel\Support\Env; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; /** * Tests for the on-disk scout config file defaults. @@ -35,6 +36,25 @@ public function testAlgoliaDefaultsArePresentInConfigFile(): void $this->assertIsArray($config['algolia']['index-settings']); } + public function testQueuedJobDefaultsArePresentInConfigFile(): void + { + $config = require dirname(__DIR__, 3) . '/src/scout/config/scout.php'; + + $this->assertSame([ + 'tries' => null, + 'backoff' => null, + 'max_exceptions' => null, + ], $config['jobs']); + } + + public function testAfterCommitHasOneTopLevelOwner(): void + { + $config = require dirname(__DIR__, 3) . '/src/scout/config/scout.php'; + + $this->assertFalse($config['after_commit']); + $this->assertArrayNotHasKey('after_commit', $config['queue']); + } + public function testMeilisearchRetryDefaultsArePresentInConfigFile(): void { $config = require dirname(__DIR__, 3) . '/src/scout/config/scout.php'; @@ -110,41 +130,54 @@ public function testMeilisearchRetryEnvironmentValuesAreLoadedAsIntegers(): void } } - public function testSoftDeleteEnvironmentValueIsLoadedAsBoolean(): void + #[DataProvider('booleanEnvironmentValues')] + public function testBooleanEnvironmentValuesAreLoadedAsBooleans(string $environmentKey, string $configKey): void { - $key = 'SCOUT_SOFT_DELETE'; - $originalPutenv = getenv($key); - $originalServerExists = array_key_exists($key, $_SERVER); - $originalServer = $_SERVER[$key] ?? null; - $originalEnvExists = array_key_exists($key, $_ENV); - $originalEnv = $_ENV[$key] ?? null; + $originalPutenv = getenv($environmentKey); + $originalServerExists = array_key_exists($environmentKey, $_SERVER); + $originalServer = $_SERVER[$environmentKey] ?? null; + $originalEnvExists = array_key_exists($environmentKey, $_ENV); + $originalEnv = $_ENV[$environmentKey] ?? null; try { - unset($_SERVER[$key], $_ENV[$key]); - putenv("{$key}=1"); + unset($_SERVER[$environmentKey], $_ENV[$environmentKey]); + putenv("{$environmentKey}=1"); Env::flushRepository(); $config = require dirname(__DIR__, 3) . '/src/scout/config/scout.php'; - $this->assertTrue($config['soft_delete']); + $this->assertTrue($config[$configKey]); } finally { $originalPutenv === false - ? putenv($key) - : putenv("{$key}={$originalPutenv}"); + ? putenv($environmentKey) + : putenv("{$environmentKey}={$originalPutenv}"); if ($originalServerExists) { - $_SERVER[$key] = $originalServer; + $_SERVER[$environmentKey] = $originalServer; } else { - unset($_SERVER[$key]); + unset($_SERVER[$environmentKey]); } if ($originalEnvExists) { - $_ENV[$key] = $originalEnv; + $_ENV[$environmentKey] = $originalEnv; } else { - unset($_ENV[$key]); + unset($_ENV[$environmentKey]); } Env::flushRepository(); } } + + /** + * Provide boolean Scout environment values. + * + * @return array + */ + public static function booleanEnvironmentValues(): array + { + return [ + 'soft deletes' => ['SCOUT_SOFT_DELETE', 'soft_delete'], + 'after commit' => ['SCOUT_AFTER_COMMIT', 'after_commit'], + ]; + } } diff --git a/tests/Scout/Unit/ConfigTest.php b/tests/Scout/Unit/ConfigTest.php index 2f51f1832..a867cd13c 100644 --- a/tests/Scout/Unit/ConfigTest.php +++ b/tests/Scout/Unit/ConfigTest.php @@ -7,6 +7,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\WaitConcurrent; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Scout\Console\ConcurrentImportRunner; use Hypervel\Scout\Events\ModelsFlushed; use Hypervel\Scout\Events\ModelsImported; use Hypervel\Scout\Scout; @@ -32,10 +33,14 @@ public function testCommandConcurrencyConfigIsUsed(): void $model->queueMakeSearchable(new Collection([$model])); }); - $scoutRunner = CoroutineContext::get(SearchableModel::SCOUT_RUNNER_CONTEXT_KEY); + $runner = CoroutineContext::get(SearchableModel::SCOUT_RUNNER_CONTEXT_KEY); - $this->assertInstanceOf(WaitConcurrent::class, $scoutRunner); - $this->assertSame(25, (new ClassInvoker($scoutRunner))->limit); + $this->assertInstanceOf(ConcurrentImportRunner::class, $runner); + + $concurrent = (new ClassInvoker($runner))->concurrent; + + $this->assertInstanceOf(WaitConcurrent::class, $concurrent); + $this->assertSame(25, (new ClassInvoker($concurrent))->limit); } public function testChunkSearchableConfigAffectsImportEvents(): void diff --git a/tests/Scout/Unit/Console/ConcurrentImportRunnerTest.php b/tests/Scout/Unit/Console/ConcurrentImportRunnerTest.php new file mode 100644 index 000000000..cc8fa39e7 --- /dev/null +++ b/tests/Scout/Unit/Console/ConcurrentImportRunnerTest.php @@ -0,0 +1,72 @@ +create(function () use (&$completed): void { + usleep(10_000); + $completed = true; + }); + + try { + $runner->create(function () use ($failure): void { + throw $failure; + }); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + try { + $runner->wait(); + $this->fail('The child import failure was not rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($completed); + } + + public function testWaitPreservesTheFirstFailureWhenMultipleChildrenFail(): void + { + $runner = new ConcurrentImportRunner(2); + $releaseFirst = new Channel(1); + $releaseSecond = new Channel(1); + $firstFailure = new RuntimeException('First failure.'); + $secondFailure = new RuntimeException('Second failure.'); + + $runner->create(function () use ($releaseFirst, $firstFailure): void { + $releaseFirst->pop(); + throw $firstFailure; + }); + + $runner->create(function () use ($releaseSecond, $secondFailure): void { + $releaseSecond->pop(); + throw $secondFailure; + }); + + $releaseFirst->push(true); + usleep(10_000); + $releaseSecond->push(true); + + try { + $runner->wait(); + $this->fail('The child import failure was not rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($firstFailure, $exception); + } + } +} diff --git a/tests/Scout/Unit/Console/ImportCommandTest.php b/tests/Scout/Unit/Console/ImportCommandTest.php index ce6bd24ec..686948ae9 100644 --- a/tests/Scout/Unit/Console/ImportCommandTest.php +++ b/tests/Scout/Unit/Console/ImportCommandTest.php @@ -4,7 +4,11 @@ namespace Hypervel\Tests\Scout\Unit\Console; +use Hypervel\Context\CoroutineContext; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Foundation\Console\Kernel; +use Hypervel\Scout\EngineManager; +use Hypervel\Scout\Engines\Engine; use Hypervel\Scout\Events\ModelsImported; use Hypervel\Scout\Exceptions\ScoutException; use Hypervel\Scout\Jobs\MakeSearchable; @@ -13,6 +17,9 @@ use Hypervel\Support\Facades\Event; use Hypervel\Tests\Scout\Models\SearchableModel; use Hypervel\Tests\Scout\ScoutTestCase; +use Mockery as m; +use RuntimeException; +use Symfony\Component\Console\Tester\CommandTester; class ImportCommandTest extends ScoutTestCase { @@ -83,4 +90,57 @@ public function testScoutImportDoesNotForgetExistingModelsImportedListeners(): v $this->assertGreaterThan(0, $countAfterImport); $this->assertSame($countAfterImport + 1, $eventCount); } + + public function testChildFailureAbortsImportDrainsTheRunnerAndDoesNotContaminateLaterImports(): void + { + $this->app->make('config')->set('scout.command_concurrency', 1); + + SearchableModel::withoutSyncingToSearch(function (): void { + for ($i = 1; $i <= 3; ++$i) { + SearchableModel::create(['title' => "Title {$i}", 'body' => 'Body']); + } + }); + + $failure = new RuntimeException('Search transport failed.'); + $failingEngine = m::mock(Engine::class); + $failingEngine->shouldReceive('update')->once()->andThrow($failure); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine')->andReturn($failingEngine); + $this->app->instance(EngineManager::class, $manager); + + $command = $this->app->make(Kernel::class)->getArtisan()->find('scout:import'); + $tester = new CommandTester(clone $command); + + try { + $tester->execute([ + 'model' => SearchableModel::class, + '--chunk' => 1, + ]); + $this->fail('The child import failure was not rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertStringNotContainsString('records have been imported', $tester->getDisplay()); + $this->assertFalse(CoroutineContext::has(SearchableModel::SCOUT_RUNNER_CONTEXT_KEY)); + + $successfulEngine = m::mock(Engine::class); + $successfulEngine->shouldReceive('update')->times(3); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine')->andReturn($successfulEngine); + $this->app->instance(EngineManager::class, $manager); + + $tester = new CommandTester(clone $command); + + $status = $tester->execute([ + 'model' => SearchableModel::class, + '--chunk' => 1, + ]); + + $this->assertSame(0, $status, $tester->getDisplay()); + $this->assertStringContainsString('records have been imported', $tester->getDisplay()); + $this->assertFalse(CoroutineContext::has(SearchableModel::SCOUT_RUNNER_CONTEXT_KEY)); + } } diff --git a/tests/Scout/Unit/Console/IndexCommandTest.php b/tests/Scout/Unit/Console/IndexCommandTest.php new file mode 100644 index 000000000..f74ff313b --- /dev/null +++ b/tests/Scout/Unit/Console/IndexCommandTest.php @@ -0,0 +1,121 @@ +shouldReceive('createIndex') + ->once() + ->with('prod_posts', ['primaryKey' => '0']); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine')->once()->andReturn($engine); + + $config = m::mock(Repository::class); + $config->shouldReceive('string')->with('scout.prefix', '')->andReturn('prod_'); + + $command = $this->command('posts', '0'); + $command->shouldReceive('info')->once()->with('Synchronized index ["prod_posts"] successfully.'); + + $this->assertSame(0, $command->handle($manager, $config)); + } + + public function testUnsupportedCreationStillAppliesLogicalIndexSettings(): void + { + $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class); + $engine->shouldReceive('createIndex') + ->once() + ->with('prod_posts', []) + ->andThrow(new NotSupportedException); + $engine->shouldReceive('updateIndexSettings') + ->once() + ->with('prod_posts', ['filterableAttributes' => ['status']]); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine')->once()->andReturn($engine); + + $config = m::mock(Repository::class); + $config->shouldReceive('string')->with('scout.prefix', '')->andReturn('prod_'); + $config->shouldReceive('string')->with('scout.driver')->andReturn('meilisearch'); + $config->shouldReceive('get') + ->with('scout.meilisearch.index-settings.posts') + ->andReturn(['filterableAttributes' => ['status']]); + + $command = $this->command('posts'); + $command->shouldReceive('info')->once()->with('Synchronized index ["prod_posts"] successfully.'); + + $this->assertSame(0, $command->handle($manager, $config)); + } + + public function testPhysicalIndexSettingsAreUsedAsFallback(): void + { + $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class); + $engine->shouldReceive('createIndex')->once()->with('prod_posts', []); + $engine->shouldReceive('updateIndexSettings') + ->once() + ->with('prod_posts', ['sortableAttributes' => ['created_at']]); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine')->once()->andReturn($engine); + + $config = m::mock(Repository::class); + $config->shouldReceive('string')->with('scout.prefix', '')->andReturn('prod_'); + $config->shouldReceive('string')->with('scout.driver')->andReturn('meilisearch'); + $config->shouldReceive('get')->with('scout.meilisearch.index-settings.posts')->andReturn(null); + $config->shouldReceive('get') + ->with('scout.meilisearch.index-settings.prod_posts') + ->andReturn(['sortableAttributes' => ['created_at']]); + + $command = $this->command('posts'); + $command->shouldReceive('info')->once()->with('Synchronized index ["prod_posts"] successfully.'); + + $this->assertSame(0, $command->handle($manager, $config)); + } + + public function testOperationalCreationFailuresPropagate(): void + { + $failure = new RuntimeException('Search service unavailable.'); + $engine = m::mock(Engine::class); + $engine->shouldReceive('createIndex')->once()->andThrow($failure); + + $manager = m::mock(EngineManager::class); + $manager->shouldReceive('engine')->once()->andReturn($engine); + + $config = m::mock(Repository::class); + $config->shouldReceive('string')->with('scout.prefix', '')->andReturn(''); + + $command = $this->command('posts'); + $command->shouldNotReceive('info'); + + try { + $command->handle($manager, $config); + $this->fail('The operational failure was swallowed.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + } + + protected function command(string $name, ?string $key = null): IndexCommand + { + $command = m::mock(IndexCommand::class)->makePartial(); + $command->shouldReceive('argument')->with('name')->andReturn($name); + $command->shouldReceive('option')->with('key')->andReturn($key); + + return $command; + } +} diff --git a/tests/Scout/Unit/Console/QueueImportCommandTest.php b/tests/Scout/Unit/Console/QueueImportCommandTest.php index 9b0acb326..8cd803efd 100644 --- a/tests/Scout/Unit/Console/QueueImportCommandTest.php +++ b/tests/Scout/Unit/Console/QueueImportCommandTest.php @@ -215,6 +215,53 @@ public function testItHandlesZeroChunkSize(): void Bus::assertDispatched(MakeRangeSearchable::class, 3); } + public function testItRejectsMalformedIntegerBounds(): void + { + Bus::fake([MakeRangeSearchable::class]); + + foreach (['01', '1.0', '1e3', 'invalid', (string) PHP_INT_MAX . '0'] as $max) { + $this->artisan('scout:queue-import', [ + 'model' => SearchableModel::class, + '--min' => '0', + '--max' => $max, + ]) + ->expectsOutputToContain('must be valid integers') + ->assertFailed(); + } + + Bus::assertNotDispatched(MakeRangeSearchable::class); + } + + public function testItAcceptsSignedIntegerBounds(): void + { + Bus::fake([MakeRangeSearchable::class]); + + $this->artisan('scout:queue-import', [ + 'model' => SearchableModel::class, + '--min' => '-2', + '--max' => '+2', + '--chunk' => 5, + ])->assertSuccessful(); + + Bus::assertDispatched(MakeRangeSearchable::class, function (MakeRangeSearchable $job) { + return $job->start === -2 && $job->end === 2; + }); + } + + public function testItRejectsAnInvalidOrder(): void + { + Bus::fake([MakeRangeSearchable::class]); + + $this->artisan('scout:queue-import', [ + 'model' => SearchableModel::class, + '--order' => 'sideways', + ]) + ->expectsOutputToContain('must be either "asc" or "desc"') + ->assertFailed(); + + Bus::assertNotDispatched(MakeRangeSearchable::class); + } + public function testItAcceptsCustomMinOption(): void { for ($i = 1; $i <= 10; ++$i) { @@ -297,6 +344,75 @@ public function testItChunksCustomRangeCorrectly(): void }); } + public function testItQueuesIntegerRangesInDescendingOrder(): void + { + Bus::fake([MakeRangeSearchable::class]); + + $this->artisan('scout:queue-import', [ + 'model' => SearchableModel::class, + '--min' => 1, + '--max' => 7, + '--chunk' => 3, + '--order' => 'desc', + ]) + ->expectsOutputToContain('models down to ID: 1') + ->assertSuccessful(); + + $ranges = Bus::dispatched(MakeRangeSearchable::class) + ->map(fn (MakeRangeSearchable $job) => [$job->start, $job->end]) + ->all(); + + $this->assertSame([[5, 7], [2, 4], [1, 1]], $ranges); + } + + public function testItQueuesPlatformMaximumIntegerRangeWithoutOverflow(): void + { + Bus::fake([MakeRangeSearchable::class]); + + $this->artisan('scout:queue-import', [ + 'model' => SearchableModel::class, + '--min' => PHP_INT_MAX - 1, + '--max' => PHP_INT_MAX, + '--chunk' => 500, + ])->assertSuccessful(); + + Bus::assertDispatched(MakeRangeSearchable::class, function (MakeRangeSearchable $job) { + return $job->start === PHP_INT_MAX - 1 && $job->end === PHP_INT_MAX; + }); + } + + public function testItQueuesPlatformMinimumIntegerRangeWithoutUnderflow(): void + { + Bus::fake([MakeRangeSearchable::class]); + + $this->artisan('scout:queue-import', [ + 'model' => SearchableModel::class, + '--min' => PHP_INT_MIN, + '--max' => PHP_INT_MIN + 1, + '--chunk' => 500, + '--order' => 'desc', + ])->assertSuccessful(); + + Bus::assertDispatched(MakeRangeSearchable::class, function (MakeRangeSearchable $job) { + return $job->start === PHP_INT_MIN && $job->end === PHP_INT_MIN + 1; + }); + } + + public function testIntegerKeyTypeAliasUsesIntegerRanges(): void + { + Bus::fake([MakeRangeSearchable::class]); + + $this->artisan('scout:queue-import', [ + 'model' => IntegerAliasSearchableModel::class, + '--min' => 0, + '--max' => 2, + ])->assertSuccessful(); + + Bus::assertDispatched(MakeRangeSearchable::class, function (MakeRangeSearchable $job) { + return $job->start === 0 && $job->end === 2; + }); + } + public function testItErrorsWhenMinGreaterThanMax(): void { Bus::fake([MakeRangeSearchable::class]); @@ -307,7 +423,7 @@ public function testItErrorsWhenMinGreaterThanMax(): void '--max' => 2, ]) ->expectsOutputToContain('Invalid range') - ->assertSuccessful(); + ->assertFailed(); Bus::assertNotDispatched(MakeRangeSearchable::class); } @@ -446,6 +562,32 @@ public function testItChunksUuidKeyedModelsCorrectly(): void Bus::assertDispatched(MakeRangeSearchable::class, 3); } + public function testItQueuesMultipleUuidChunksInDescendingOrder(): void + { + for ($i = 1; $i <= 7; ++$i) { + UuidSearchableModel::create(['title' => "Title {$i}", 'body' => 'Body']); + } + + $expected = UuidSearchableModel::query()->orderByDesc('id')->pluck('id')->chunk(3)->values(); + + Bus::fake([MakeRangeSearchable::class]); + + $this->artisan('scout:queue-import', [ + 'model' => UuidSearchableModel::class, + '--chunk' => 3, + '--order' => 'desc', + ])->assertSuccessful(); + + $jobs = Bus::dispatched(MakeRangeSearchable::class)->values(); + + $this->assertCount(3, $jobs); + + foreach ($expected as $index => $keys) { + $this->assertSame($keys->last(), $jobs[$index]->start); + $this->assertSame($keys->first(), $jobs[$index]->end); + } + } + public function testItHandlesNoRecordsForUuidModel(): void { Bus::fake([MakeRangeSearchable::class]); @@ -481,7 +623,7 @@ public function testItAcceptsMinMaxFiltersForUuidKeys(): void }); } - public function testItErrorsWhenStringMinGreaterThanMax(): void + public function testDatabaseCollationHandlesReversedStringBoundsAsAnEmptyRange(): void { for ($i = 1; $i <= 3; ++$i) { UuidSearchableModel::create(['title' => "Title {$i}", 'body' => 'Body']); @@ -496,7 +638,7 @@ public function testItErrorsWhenStringMinGreaterThanMax(): void '--min' => $rows->last()->id, '--max' => $rows->first()->id, ]) - ->expectsOutputToContain('Invalid range') + ->expectsOutputToContain('No records found') ->assertSuccessful(); Bus::assertNotDispatched(MakeRangeSearchable::class); @@ -536,3 +678,11 @@ public function testItDispatchesJobsWithStringStartAndEnd(): void }); } } + +class IntegerAliasSearchableModel extends SearchableModel +{ + public function getScoutKeyType(): string + { + return 'integer'; + } +} diff --git a/tests/Scout/Unit/EngineManagerTest.php b/tests/Scout/Unit/EngineManagerTest.php index c767db3d7..3ccf0005e 100644 --- a/tests/Scout/Unit/EngineManagerTest.php +++ b/tests/Scout/Unit/EngineManagerTest.php @@ -24,7 +24,7 @@ class EngineManagerTest extends TestCase { - public function testResolveNullEngine() + public function testResolveNullEngine(): void { $container = $this->createMockContainer(['driver' => 'null']); @@ -34,7 +34,7 @@ public function testResolveNullEngine() $this->assertInstanceOf(NullEngine::class, $engine); } - public function testResolveCollectionEngine() + public function testResolveCollectionEngine(): void { $container = $this->createMockContainer(['driver' => 'collection']); @@ -44,7 +44,7 @@ public function testResolveCollectionEngine() $this->assertInstanceOf(CollectionEngine::class, $engine); } - public function testResolveAlgoliaEngine() + public function testResolveAlgoliaEngine(): void { $container = $this->createMockContainerWithAlgolia([ 'driver' => 'algolia', @@ -63,7 +63,7 @@ public function testResolveAlgoliaEngine() $this->assertInstanceOf(AlgoliaEngine::class, $engine); } - public function testResolveAlgoliaEngineWithSoftDelete() + public function testResolveAlgoliaEngineWithSoftDelete(): void { $container = $this->createMockContainerWithAlgolia([ 'driver' => 'algolia', @@ -83,7 +83,7 @@ public function testResolveAlgoliaEngineWithSoftDelete() $this->assertTrue((new ClassInvoker($engine))->softDelete); } - public function testResolveAlgoliaEngineWithIdentify() + public function testResolveAlgoliaEngineWithIdentify(): void { $container = $this->createMockContainerWithAlgolia([ 'driver' => 'algolia', @@ -103,7 +103,7 @@ public function testResolveAlgoliaEngineWithIdentify() $this->assertTrue((new ClassInvoker($engine))->identify); } - public function testResolveMeilisearchEngine() + public function testResolveMeilisearchEngine(): void { $container = $this->createMockContainer([ 'driver' => 'meilisearch', @@ -121,7 +121,7 @@ public function testResolveMeilisearchEngine() $this->assertInstanceOf(MeilisearchEngine::class, $engine); } - public function testResolveMeilisearchEngineWithSoftDelete() + public function testResolveMeilisearchEngineWithSoftDelete(): void { $container = $this->createMockContainer([ 'driver' => 'meilisearch', @@ -139,7 +139,7 @@ public function testResolveMeilisearchEngineWithSoftDelete() $this->assertInstanceOf(MeilisearchEngine::class, $engine); } - public function testResolveDatabaseEngine() + public function testResolveDatabaseEngine(): void { $container = $this->createMockContainer(['driver' => 'database']); @@ -149,7 +149,7 @@ public function testResolveDatabaseEngine() $this->assertInstanceOf(DatabaseEngine::class, $engine); } - public function testResolveTypesenseEngine() + public function testResolveTypesenseEngine(): void { $container = $this->createMockContainerWithTypesense([ 'driver' => 'typesense', @@ -167,7 +167,7 @@ public function testResolveTypesenseEngine() $this->assertInstanceOf(TypesenseEngine::class, $engine); } - public function testEngineUsesDefaultDriver() + public function testEngineUsesDefaultDriver(): void { $container = $this->createMockContainer(['driver' => 'collection']); @@ -177,7 +177,7 @@ public function testEngineUsesDefaultDriver() $this->assertInstanceOf(CollectionEngine::class, $engine); } - public function testEngineDefaultsToNullWhenNoDriverConfigured() + public function testEngineDefaultsToNullWhenNoDriverConfigured(): void { $container = $this->createMockContainer(['driver' => null]); @@ -187,7 +187,7 @@ public function testEngineDefaultsToNullWhenNoDriverConfigured() $this->assertInstanceOf(NullEngine::class, $engine); } - public function testEngineCachesInstances() + public function testEngineCachesInstances(): void { $container = $this->createMockContainer(['driver' => 'collection']); @@ -199,7 +199,7 @@ public function testEngineCachesInstances() $this->assertSame($engine1, $engine2); } - public function testForgetEnginesClearsCache() + public function testForgetEnginesClearsCache(): void { $container = $this->createMockContainer(['driver' => 'collection']); @@ -212,7 +212,7 @@ public function testForgetEnginesClearsCache() $this->assertNotSame($engine1, $engine2); } - public function testForgetEngineClearsSpecificEngine() + public function testForgetEngineClearsSpecificEngine(): void { $container = $this->createMockContainer(['driver' => 'collection']); @@ -230,7 +230,7 @@ public function testForgetEngineClearsSpecificEngine() $this->assertSame($null1, $null2); } - public function testExtendRegisterCustomDriver() + public function testExtendRegisterCustomDriver(): void { $container = $this->createMockContainer(['driver' => 'custom']); @@ -246,7 +246,7 @@ public function testExtendRegisterCustomDriver() $this->assertSame($customEngine, $engine); } - public function testExtendCustomDriverReceivesContainer() + public function testExtendCustomDriverReceivesContainer(): void { $container = $this->createMockContainer(['driver' => 'custom']); @@ -264,7 +264,7 @@ public function testExtendCustomDriverReceivesContainer() $this->assertSame($container, $receivedContainer); } - public function testCustomDriverOverridesBuiltIn() + public function testCustomDriverOverridesBuiltIn(): void { $container = $this->createMockContainer(['driver' => 'collection']); @@ -280,7 +280,7 @@ public function testCustomDriverOverridesBuiltIn() $this->assertSame($customEngine, $engine); } - public function testThrowsExceptionForUnsupportedDriver() + public function testThrowsExceptionForUnsupportedDriver(): void { $container = $this->createMockContainer(['driver' => 'unsupported']); @@ -292,7 +292,7 @@ public function testThrowsExceptionForUnsupportedDriver() $manager->engine('unsupported'); } - public function testGetDefaultDriverReturnsConfiguredDriver() + public function testGetDefaultDriverReturnsConfiguredDriver(): void { $container = $this->createMockContainer(['driver' => 'meilisearch']); @@ -301,7 +301,7 @@ public function testGetDefaultDriverReturnsConfiguredDriver() $this->assertSame('meilisearch', $manager->getDefaultDriver()); } - public function testGetDefaultDriverReturnsNullWhenNotConfigured() + public function testGetDefaultDriverReturnsNullWhenNotConfigured(): void { $container = $this->createMockContainer(['driver' => null]); @@ -310,7 +310,7 @@ public function testGetDefaultDriverReturnsNullWhenNotConfigured() $this->assertSame('null', $manager->getDefaultDriver()); } - public function testSeparateManagerInstancesDoNotShareEngineCache() + public function testSeparateManagerInstancesDoNotShareEngineCache(): void { $container = $this->createMockContainer(['driver' => 'collection']); diff --git a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php index e8096d491..d2fb98ec3 100644 --- a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php +++ b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php @@ -19,13 +19,15 @@ use Hypervel\Scout\Searchable; use Hypervel\Support\LazyCollection; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use function Hypervel\Coroutine\go; class AlgoliaEngineTest extends TestCase { - public function testUpdateSavesObjects() + public function testUpdateSavesObjects(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -46,7 +48,7 @@ public function testUpdateSavesObjects() $engine->update(new EloquentCollection([$model])); } - public function testUpdateEmptyCollectionDoesNothing() + public function testUpdateEmptyCollectionDoesNothing(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldNotReceive('saveObjects'); @@ -57,7 +59,7 @@ public function testUpdateEmptyCollectionDoesNothing() $this->assertTrue(true); } - public function testUpdateFiltersOutEmptySearchableArrays() + public function testUpdateFiltersOutEmptySearchableArrays(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldNotReceive('saveObjects'); @@ -71,7 +73,7 @@ public function testUpdateFiltersOutEmptySearchableArrays() $engine->update(new EloquentCollection([$model])); } - public function testUpdateIncludesScoutMetadata() + public function testUpdateIncludesScoutMetadata(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -92,7 +94,7 @@ public function testUpdateIncludesScoutMetadata() $engine->update(new EloquentCollection([$model])); } - public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata() + public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -114,7 +116,7 @@ public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata() $engine->update(new EloquentCollection([$model])); } - public function testDeleteRemovesObjects() + public function testDeleteRemovesObjects(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -132,7 +134,7 @@ public function testDeleteRemovesObjects() $engine->delete(new EloquentCollection([$model])); } - public function testDeleteWithCustomScoutKey() + public function testDeleteWithCustomScoutKey(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -150,7 +152,7 @@ public function testDeleteWithCustomScoutKey() $engine->delete(new EloquentCollection([$model])); } - public function testDeleteWithRemoveableScoutCollection() + public function testDeleteWithRemoveableScoutCollection(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -173,7 +175,7 @@ public function testDeleteWithRemoveableScoutCollection() $engine->delete($collection); } - public function testDeleteEmptyCollection() + public function testDeleteEmptyCollection(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldNotReceive('deleteObjects'); @@ -184,7 +186,7 @@ public function testDeleteEmptyCollection() $this->assertTrue(true); } - public function testDeleteIndex() + public function testDeleteIndex(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -200,7 +202,7 @@ public function testDeleteIndex() $this->assertEquals(['taskID' => 123], $result); } - public function testFlush() + public function testFlush(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -216,7 +218,7 @@ public function testFlush() $engine->flush($model); } - public function testUpdateIndexSettings() + public function testUpdateIndexSettings(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -228,7 +230,7 @@ public function testUpdateIndexSettings() $engine->updateIndexSettings('users', ['searchableAttributes' => ['name', 'email']]); } - public function testCreateIndexThrows() + public function testCreateIndexThrows(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -238,13 +240,13 @@ public function testCreateIndexThrows() $engine->createIndex('users'); } - public function testSearchSendsCorrectParameters() + public function testSearchSendsCorrectParameters(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldReceive('searchSingleIndex') ->once() - ->with('users', ['query' => 'zonda', 'numericFilters' => ['foo=1']], []); + ->with('users', ['query' => 'zonda', 'filters' => "foo:'1'"], []); $engine = new AlgoliaEngine($client); @@ -257,13 +259,125 @@ public function testSearchSendsCorrectParameters() $engine->search($builder); } - public function testSearchSendsCorrectParametersForWhereInSearch() + public function testSearchSendsBooleanInequalityAndEscapedFilters(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldReceive('searchSingleIndex') ->once() - ->with('users', ['query' => 'zonda', 'numericFilters' => ['foo=1', ['bar=1', 'bar=2']]], []); + ->with('users', [ + 'query' => 'zonda', + 'filters' => "is_live:true AND is_archived:false AND NOT status:'draft' AND NOT label:'manager\\'s draft\\\\review' AND NOT is_deleted:true AND is_featured>1 AND is_disabled<=0 AND rank>=10 AND score>=9223372036854775808 AND ratio<1.25", + ], []); + + $engine = new AlgoliaEngine($client); + + $model = m::mock(AlgoliaTestSearchableModel::class); + $model->shouldReceive('searchableAs')->andReturn('users'); + + $builder = new Builder($model, 'zonda'); + $builder->where('is_live', true) + ->where('is_archived', '=', false) + ->where('status', '!=', 'draft') + ->where('label', '!=', "manager's draft\\review") + ->where('is_deleted', '!=', true) + ->where('is_featured', '>', true) + ->where('is_disabled', '<=', false) + ->where('rank', '>=', 10) + ->where('score', '>=', '9223372036854775808') + ->where('ratio', '<', 1.25); + + $engine->search($builder); + } + + #[DataProvider('invalidFilterValues')] + public function testSearchRejectsInvalidFilters(string $operator, mixed $value, string $message): void + { + $client = m::mock(AlgoliaSearchClient::class); + $client->shouldNotReceive('searchSingleIndex'); + + $engine = new AlgoliaEngine($client); + $builder = new Builder(m::mock(AlgoliaTestSearchableModel::class), 'zonda'); + $builder->where('value', $operator, $value); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $engine->search($builder); + } + + /** + * Provide invalid Algolia filter values. + * + * @return array + */ + public static function invalidFilterValues(): array + { + return [ + 'unsupported operator' => ['LIKE', "a' OR x:'y", 'Unsupported Algolia filter operator [LIKE].'], + 'null equality' => ['=', null, 'Algolia filters do not support null values.'], + 'null inequality' => ['!=', null, 'Algolia filters do not support null values.'], + 'null ordering' => ['>', null, 'Algolia filters do not support null values.'], + 'nonnumeric string ordering' => ['>', "1 OR x:'2", 'Algolia ordering filters require a numeric value.'], + 'leading-zero string ordering' => ['>', '01', 'Algolia ordering filters require a numeric value.'], + 'exponent string ordering' => ['>', '1e3', 'Algolia ordering filters require a numeric value.'], + 'non-scalar ordering' => ['>', [1, 2], 'Algolia ordering filters require a numeric value.'], + 'infinite float' => ['>', INF, 'Algolia filters require finite numeric values.'], + 'not-a-number float' => ['=', NAN, 'Algolia filters require finite numeric values.'], + ]; + } + + public function testSearchSendsBackedEnumFilters(): void + { + $client = m::mock(AlgoliaSearchClient::class); + + $client->shouldReceive('searchSingleIndex') + ->once() + ->with('users', [ + 'query' => 'zonda', + 'filters' => "status:'published' AND priority:'1' AND (statuses:'draft' OR statuses:'published') AND NOT priorities:'1' AND NOT priorities:'2'", + ], []); + + $engine = new AlgoliaEngine($client); + + $model = m::mock(AlgoliaTestSearchableModel::class); + $model->shouldReceive('searchableAs')->andReturn('users'); + + $builder = new Builder($model, 'zonda'); + $builder->where('status', AlgoliaStringStatus::Published) + ->where('priority', AlgoliaIntegerPriority::High) + ->whereIn('statuses', [AlgoliaStringStatus::Draft, AlgoliaStringStatus::Published]) + ->whereNotIn('priorities', [AlgoliaIntegerPriority::High, AlgoliaIntegerPriority::Low]); + + $engine->search($builder); + } + + public function testSearchPreservesStringZeroIndex(): void + { + $client = m::mock(AlgoliaSearchClient::class); + + $client->shouldReceive('searchSingleIndex') + ->once() + ->with('0', ['query' => 'zonda'], []); + + $engine = new AlgoliaEngine($client); + + $model = m::mock(AlgoliaTestSearchableModel::class); + $model->shouldNotReceive('searchableAs'); + + $builder = new Builder($model, 'zonda'); + $builder->within('0'); + + $engine->search($builder); + } + + public function testSearchSendsCorrectParametersForWhereInSearch(): void + { + $client = m::mock(AlgoliaSearchClient::class); + + $client->shouldReceive('searchSingleIndex') + ->once() + ->with('users', ['query' => 'zonda', 'filters' => "foo:'1' AND (bar:'1' OR bar:'2')"], []); $engine = new AlgoliaEngine($client); @@ -276,13 +390,13 @@ public function testSearchSendsCorrectParametersForWhereInSearch() $engine->search($builder); } - public function testSearchSendsCorrectParametersForEmptyWhereInSearch() + public function testSearchSendsCorrectParametersForEmptyWhereInSearch(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldReceive('searchSingleIndex') ->once() - ->with('users', ['query' => 'zonda', 'numericFilters' => ['foo=1', '0=1']], []); + ->with('users', ['query' => 'zonda', 'filters' => "foo:'1' AND 0:1"], []); $engine = new AlgoliaEngine($client); @@ -295,13 +409,13 @@ public function testSearchSendsCorrectParametersForEmptyWhereInSearch() $engine->search($builder); } - public function testSearchSendsCorrectParametersForWhereNotInSearch() + public function testSearchSendsCorrectParametersForWhereNotInSearch(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldReceive('searchSingleIndex') ->once() - ->with('users', ['query' => 'zonda', 'numericFilters' => ['foo!=1', 'foo!=2']], []); + ->with('users', ['query' => 'zonda', 'filters' => "NOT foo:'1' AND NOT foo:'2'"], []); $engine = new AlgoliaEngine($client); @@ -314,7 +428,7 @@ public function testSearchSendsCorrectParametersForWhereNotInSearch() $engine->search($builder); } - public function testSearchIgnoresEmptyWhereNotIn() + public function testSearchIgnoresEmptyWhereNotIn(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -333,7 +447,7 @@ public function testSearchIgnoresEmptyWhereNotIn() $engine->search($builder); } - public function testSearchSendsCorrectParametersForMixedSearch() + public function testSearchSendsCorrectParametersForMixedSearch(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -341,7 +455,7 @@ public function testSearchSendsCorrectParametersForMixedSearch() ->once() ->with( 'users', - ['query' => 'zonda', 'numericFilters' => ['foo=1', ['bar=1', 'bar=2'], 'baz!=1', 'baz!=2']], + ['query' => 'zonda', 'filters' => "foo:'1' AND (bar:'1' OR bar:'2') AND NOT baz:'1' AND NOT baz:'2'"], [], ); @@ -358,7 +472,7 @@ public function testSearchSendsCorrectParametersForMixedSearch() $engine->search($builder); } - public function testSearchWithCallbackUsesCallback() + public function testSearchWithCallbackUsesCallback(): void { $client = m::mock(AlgoliaSearchClient::class); $client->shouldNotReceive('searchSingleIndex'); @@ -383,7 +497,7 @@ public function testSearchWithCallbackUsesCallback() $this->assertEquals(['hits' => [], 'nbHits' => 0], $result); } - public function testPaginateComputesCorrectPage() + public function testPaginateComputesCorrectPage(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -403,7 +517,7 @@ public function testPaginateComputesCorrectPage() $engine->paginate($builder, 15, 2); } - public function testMapReturnsEmptyWhenNoHits() + public function testMapReturnsEmptyWhenNoHits(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -418,7 +532,7 @@ public function testMapReturnsEmptyWhenNoHits() $this->assertCount(0, $results); } - public function testMapPopulatesScoutMetadataFromUnderscoreKeys() + public function testMapPopulatesScoutMetadataFromUnderscoreKeys(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -447,7 +561,7 @@ public function testMapPopulatesScoutMetadataFromUnderscoreKeys() $this->assertCount(1, $results); } - public function testMapRespectsOrder() + public function testMapRespectsOrder(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -483,7 +597,7 @@ public function testMapRespectsOrder() $this->assertEquals([1, 2, 4, 3], $resultIds); } - public function testLazyMapRespectsOrder() + public function testLazyMapRespectsOrder(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -520,7 +634,7 @@ public function testLazyMapRespectsOrder() $this->assertEquals([1, 2, 4, 3], $resultIds); } - public function testGetTotalCount() + public function testGetTotalCount(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -528,7 +642,7 @@ public function testGetTotalCount() $this->assertEquals(42, $engine->getTotalCount(['nbHits' => 42])); } - public function testMapIdsReturnsObjectIDs() + public function testMapIdsReturnsObjectIDs(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -545,7 +659,7 @@ public function testMapIdsReturnsObjectIDs() $this->assertEquals(['a', 'b', 'c'], $results->all()); } - public function testConfigureSoftDeleteFilter() + public function testConfigureSoftDeleteFilter(): void { $client = m::mock(AlgoliaSearchClient::class); $engine = new AlgoliaEngine($client); @@ -560,7 +674,7 @@ public function testConfigureSoftDeleteFilter() ); } - public function testIdentifyDisabledSendsEmptyRequestOptions() + public function testIdentifyDisabledSendsEmptyRequestOptions(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -578,7 +692,7 @@ public function testIdentifyDisabledSendsEmptyRequestOptions() $engine->search($builder); } - public function testIdentifyEnabledSendsHeadersFromRequest() + public function testIdentifyEnabledSendsHeadersFromRequest(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -599,7 +713,7 @@ public function testIdentifyEnabledSendsHeadersFromRequest() $engine->search($builder); } - public function testIdentifyEnabledWithAuthenticatedUserAddsUserToken() + public function testIdentifyEnabledWithAuthenticatedUserAddsUserToken(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -629,7 +743,7 @@ public function testIdentifyEnabledWithAuthenticatedUserAddsUserToken() $engine->search($builder); } - public function testIdentifyIgnoresPrivateIPs() + public function testIdentifyIgnoresPrivateIPs(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -650,7 +764,7 @@ public function testIdentifyIgnoresPrivateIPs() $engine->search($builder); } - public function testIdentifyHeadersReflectTheCurrentRequestNotTheCachedOne() + public function testIdentifyHeadersReflectTheCurrentRequestNotTheCachedOne(): void { // Regression test for the deliberate divergence from Laravel's // EngineManager::defaultAlgoliaHeaders(). The engine is constructed @@ -692,7 +806,7 @@ public function testIdentifyHeadersReflectTheCurrentRequestNotTheCachedOne() $this->assertSame(['203.0.113.10', '198.51.100.20'], $capturedHeaders); } - public function testIdentifyHeadersDoNotLeakAcrossCoroutines() + public function testIdentifyHeadersDoNotLeakAcrossCoroutines(): void { // Companion regression test: RequestContext is coroutine-local, so // two concurrent coroutines each seeding their own request must each @@ -737,7 +851,7 @@ public function testIdentifyHeadersDoNotLeakAcrossCoroutines() $this->assertSame(['198.51.100.20', '203.0.113.10'], $capturedHeaders); } - public function testUpdateSoftDeletedWithEmptySearchableArrayDoesNotSave() + public function testUpdateSoftDeletedWithEmptySearchableArrayDoesNotSave(): void { // Covers the Laravel Feature-test gap (test_update_empty_searchable_ // array_from_soft_deleted_model_does_not_add_objects_to_index). With @@ -757,7 +871,7 @@ public function testUpdateSoftDeletedWithEmptySearchableArrayDoesNotSave() $engine->update(new EloquentCollection([$model])); } - public function testCallForwardsUnknownMethodsToClient() + public function testCallForwardsUnknownMethodsToClient(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -771,7 +885,7 @@ public function testCallForwardsUnknownMethodsToClient() $this->assertEquals('result', $engine->customMethod('arg')); } - public function testDeleteAllIndexesWithPrefixScopesToPrefixedIndexes() + public function testDeleteAllIndexesWithPrefixScopesToPrefixedIndexes(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -793,7 +907,7 @@ public function testDeleteAllIndexesWithPrefixScopesToPrefixedIndexes() $engine->deleteAllIndexes('test_'); } - public function testDeleteAllIndexesWithNullPrefixDeletesEverything() + public function testDeleteAllIndexesWithNullPrefixDeletesEverything(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -815,7 +929,7 @@ public function testDeleteAllIndexesWithNullPrefixDeletesEverything() $engine->deleteAllIndexes(null); } - public function testDeleteAllIndexesWithEmptyStringPrefixDeletesEverything() + public function testDeleteAllIndexesWithEmptyStringPrefixDeletesEverything(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -837,7 +951,7 @@ public function testDeleteAllIndexesWithEmptyStringPrefixDeletesEverything() $engine->deleteAllIndexes(''); } - public function testDeleteAllIndexesReturnsResponses() + public function testDeleteAllIndexesReturnsResponses(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -859,7 +973,7 @@ public function testDeleteAllIndexesReturnsResponses() $this->assertEquals([['taskID' => 1], ['taskID' => 2]], $responses); } - public function testDeleteAllIndexesWithNoIndexesReturnsEmptyArray() + public function testDeleteAllIndexesWithNoIndexesReturnsEmptyArray(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -875,7 +989,7 @@ public function testDeleteAllIndexesWithNoIndexesReturnsEmptyArray() $this->assertEquals([], $result); } - public function testDeleteAllIndexesSkipsEntriesWithoutStringNames() + public function testDeleteAllIndexesSkipsEntriesWithoutStringNames(): void { $client = m::mock(AlgoliaSearchClient::class); @@ -993,3 +1107,15 @@ public function getKey(): mixed return $this->key; } } + +enum AlgoliaStringStatus: string +{ + case Draft = 'draft'; + case Published = 'published'; +} + +enum AlgoliaIntegerPriority: int +{ + case Low = 2; + case High = 1; +} diff --git a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php index 6c37f5b39..c9b185d32 100644 --- a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php +++ b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php @@ -4,23 +4,26 @@ namespace Hypervel\Tests\Scout\Unit\Engines; +use GuzzleHttp\Psr7\Response; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Scout\Builder; use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Engines\MeilisearchEngine; +use Hypervel\Scout\Jobs\RemoveableScoutCollection; use Hypervel\Scout\Searchable; use Hypervel\Support\LazyCollection; use Hypervel\Tests\TestCase; use Meilisearch\Client; use Meilisearch\Contracts\IndexesResults; use Meilisearch\Endpoints\Indexes; +use Meilisearch\Exceptions\ApiException; use Mockery as m; class MeilisearchEngineTest extends TestCase { - public function testUpdateAddsDocumentsToIndex() + public function testUpdateAddsDocumentsToIndex(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -48,7 +51,7 @@ public function testUpdateAddsDocumentsToIndex() $engine->update(new EloquentCollection([$model])); } - public function testUpdateEmptyCollectionDoesNothing() + public function testUpdateEmptyCollectionDoesNothing(): void { $client = m::mock(Client::class); $client->shouldNotReceive('index'); @@ -59,7 +62,7 @@ public function testUpdateEmptyCollectionDoesNothing() $this->assertTrue(true); } - public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata() + public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -88,7 +91,7 @@ public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata() $engine->update(new EloquentCollection([$model])); } - public function testDeleteRemovesDocumentsFromIndex() + public function testDeleteRemovesDocumentsFromIndex(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -114,7 +117,29 @@ public function testDeleteRemovesDocumentsFromIndex() $engine->delete(new EloquentCollection([$model1, $model2])); } - public function testDeleteEmptyCollectionDoesNothing() + public function testDeleteWithRemoveableScoutCollectionUsesStoredScoutKey(): void + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('chirps') + ->once() + ->andReturn($index); + + $index->shouldReceive('deleteDocuments') + ->once() + ->with(['stored-scout-key']); + + $engine = new MeilisearchEngine($client); + + $model = new MeilisearchTestChirpModel; + $model->setRawAttributes(['scout_id' => 'stored-scout-key']); + + $engine->delete(new RemoveableScoutCollection([$model])); + } + + public function testDeleteEmptyCollectionDoesNothing(): void { $client = m::mock(Client::class); $client->shouldNotReceive('index'); @@ -125,7 +150,7 @@ public function testDeleteEmptyCollectionDoesNothing() $this->assertTrue(true); } - public function testSearchPerformsSearchOnMeilisearch() + public function testSearchPerformsSearchOnMeilisearch(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -153,7 +178,41 @@ public function testSearchPerformsSearchOnMeilisearch() $this->assertEquals(['hits' => [], 'totalHits' => 0], $result); } - public function testSearchWithFilters() + public function testSearchWithFilters(): void + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $index->shouldReceive('rawSearch') + ->once() + ->with('query', m::on(function ($params) { + return $params['filter'] === 'status="active" AND score>=10 AND label!="draft\"review\\\copy" AND archived_at IS NOT NULL AND tags IN ["a\"b\\\c", true] AND hidden NOT IN ["draft", false]'; + })) + ->andReturn(['hits' => [], 'totalHits' => 0]); + + $engine = new MeilisearchEngine($client); + + $model = m::mock(MeilisearchTestSearchableModel::class); + $model->shouldReceive('searchableAs')->andReturn('test_index'); + $model->shouldReceive('getScoutKeyName')->andReturn('id'); + + $builder = new Builder($model, 'query'); + $builder->where('status', 'active') + ->where('score', '>=', 10) + ->where('label', '!=', 'draft"review\copy') + ->where('archived_at', '!=', null) + ->whereIn('tags', ['a"b\c', true]) + ->whereNotIn('hidden', ['draft', false]); + + $engine->search($builder); + } + + public function testSearchWithBackedEnumFilters(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -166,7 +225,7 @@ public function testSearchWithFilters() $index->shouldReceive('rawSearch') ->once() ->with('query', m::on(function ($params) { - return str_contains($params['filter'], 'status="active"'); + return $params['filter'] === 'status="published" AND priority=1 AND statuses IN ["draft", "published"] AND priorities NOT IN [1, 2]'; })) ->andReturn(['hits' => [], 'totalHits' => 0]); @@ -177,12 +236,15 @@ public function testSearchWithFilters() $model->shouldReceive('getScoutKeyName')->andReturn('id'); $builder = new Builder($model, 'query'); - $builder->where('status', 'active'); + $builder->where('status', MeilisearchStringStatus::Published) + ->where('priority', MeilisearchIntegerPriority::High) + ->whereIn('statuses', [MeilisearchStringStatus::Draft, MeilisearchStringStatus::Published]) + ->whereNotIn('priorities', [MeilisearchIntegerPriority::High, MeilisearchIntegerPriority::Low]); $engine->search($builder); } - public function testPaginatePerformsPaginatedSearch() + public function testPaginatePerformsPaginatedSearch(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -210,7 +272,7 @@ public function testPaginatePerformsPaginatedSearch() $engine->paginate($builder, 15, 2); } - public function testMapIdsReturnsEmptyCollectionIfNoHits() + public function testMapIdsReturnsEmptyCollectionIfNoHits(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -223,7 +285,7 @@ public function testMapIdsReturnsEmptyCollectionIfNoHits() $this->assertCount(0, $results); } - public function testMapIdsReturnsCorrectPrimaryKeys() + public function testMapIdsReturnsCorrectPrimaryKeys(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -241,7 +303,7 @@ public function testMapIdsReturnsCorrectPrimaryKeys() $this->assertEquals([1, 2, 3, 4], $results->all()); } - public function testMapCorrectlyMapsResultsToModels() + public function testMapCorrectlyMapsResultsToModels(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -272,7 +334,7 @@ public function testMapCorrectlyMapsResultsToModels() $this->assertCount(1, $results); } - public function testMapReturnsEmptyCollectionWhenNoHits() + public function testMapReturnsEmptyCollectionWhenNoHits(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -287,7 +349,7 @@ public function testMapReturnsEmptyCollectionWhenNoHits() $this->assertCount(0, $results); } - public function testMapRespectsOrder() + public function testMapRespectsOrder(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -326,7 +388,7 @@ public function testMapRespectsOrder() $this->assertEquals([1, 2, 4, 3], $resultIds); } - public function testLazyMapReturnsEmptyCollectionWhenNoHits() + public function testLazyMapReturnsEmptyCollectionWhenNoHits(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -342,7 +404,7 @@ public function testLazyMapReturnsEmptyCollectionWhenNoHits() $this->assertCount(0, $results); } - public function testGetTotalCountReturnsTotalHits() + public function testGetTotalCountReturnsTotalHits(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -350,7 +412,7 @@ public function testGetTotalCountReturnsTotalHits() $this->assertSame(3, $engine->getTotalCount(['totalHits' => 3])); } - public function testGetTotalCountReturnsEstimatedTotalHits() + public function testGetTotalCountReturnsEstimatedTotalHits(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -358,7 +420,7 @@ public function testGetTotalCountReturnsEstimatedTotalHits() $this->assertSame(5, $engine->getTotalCount(['estimatedTotalHits' => 5])); } - public function testGetTotalCountReturnsZeroWhenNoCountAvailable() + public function testGetTotalCountReturnsZeroWhenNoCountAvailable(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -366,7 +428,7 @@ public function testGetTotalCountReturnsZeroWhenNoCountAvailable() $this->assertSame(0, $engine->getTotalCount([])); } - public function testFlushDeletesAllDocuments() + public function testFlushDeletesAllDocuments(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -386,17 +448,14 @@ public function testFlushDeletesAllDocuments() $engine->flush($model); } - public function testCreateIndexCreatesNewIndex() + public function testCreateIndexCreatesNewIndex(): void { $client = m::mock(Client::class); $client->shouldReceive('getIndex') ->with('test_index') ->once() - ->andThrow(new \Meilisearch\Exceptions\ApiException( - new \GuzzleHttp\Psr7\Response(404), - ['message' => 'Index not found'] - )); + ->andThrow($this->apiException(404, 'index_not_found')); $taskInfo = ['taskUid' => 1, 'indexUid' => 'test_index', 'status' => 'enqueued']; $client->shouldReceive('createIndex') @@ -411,7 +470,7 @@ public function testCreateIndexCreatesNewIndex() $this->assertSame($taskInfo, $result); } - public function testCreateIndexReturnsExistingIndex() + public function testCreateIndexReturnsExistingIndex(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -432,7 +491,75 @@ public function testCreateIndexReturnsExistingIndex() $this->assertSame($index, $result); } - public function testDeleteIndexDeletesIndex() + public function testCreateIndexPropagatesNonNotFoundLookupFailure(): void + { + $client = m::mock(Client::class); + $exception = $this->apiException(401, 'invalid_api_key'); + + $client->shouldReceive('getIndex') + ->with('test_index') + ->once() + ->andThrow($exception); + + $client->shouldNotReceive('createIndex'); + + $engine = new MeilisearchEngine($client); + + $this->expectExceptionObject($exception); + + $engine->createIndex('test_index'); + } + + public function testCreateIndexReturnsIndexWhenCreationLosesRace(): void + { + $client = m::mock(Client::class); + $index = m::mock(Indexes::class); + + $client->shouldReceive('getIndex') + ->with('test_index') + ->once() + ->andThrow($this->apiException(404, 'index_not_found')); + + $client->shouldReceive('createIndex') + ->with('test_index', []) + ->once() + ->andThrow($this->apiException(409, 'index_already_exists')); + + $client->shouldReceive('index') + ->with('test_index') + ->once() + ->andReturn($index); + + $engine = new MeilisearchEngine($client); + + $this->assertSame($index, $engine->createIndex('test_index')); + } + + public function testCreateIndexPropagatesOtherCreateConflict(): void + { + $client = m::mock(Client::class); + $exception = $this->apiException(409, 'invalid_index_uid'); + + $client->shouldReceive('getIndex') + ->with('test_index') + ->once() + ->andThrow($this->apiException(404, 'index_not_found')); + + $client->shouldReceive('createIndex') + ->with('test_index', []) + ->once() + ->andThrow($exception); + + $client->shouldNotReceive('index'); + + $engine = new MeilisearchEngine($client); + + $this->expectExceptionObject($exception); + + $engine->createIndex('test_index'); + } + + public function testDeleteIndexDeletesIndex(): void { $client = m::mock(Client::class); @@ -448,7 +575,7 @@ public function testDeleteIndexDeletesIndex() $this->assertEquals(['taskUid' => 1], $result); } - public function testDeleteAllIndexesWithPrefixScopesToPrefixedUids() + public function testDeleteAllIndexesWithPrefixScopesToPrefixedUids(): void { $client = m::mock(Client::class); @@ -473,7 +600,7 @@ public function testDeleteAllIndexesWithPrefixScopesToPrefixedUids() $engine->deleteAllIndexes('test_'); } - public function testDeleteAllIndexesWithNullPrefixDeletesEverything() + public function testDeleteAllIndexesWithNullPrefixDeletesEverything(): void { $client = m::mock(Client::class); @@ -498,7 +625,7 @@ public function testDeleteAllIndexesWithNullPrefixDeletesEverything() $engine->deleteAllIndexes(null); } - public function testDeleteAllIndexesWithEmptyStringPrefixDeletesEverything() + public function testDeleteAllIndexesWithEmptyStringPrefixDeletesEverything(): void { $client = m::mock(Client::class); @@ -523,7 +650,7 @@ public function testDeleteAllIndexesWithEmptyStringPrefixDeletesEverything() $engine->deleteAllIndexes(''); } - public function testDeleteAllIndexesReturnsTasks() + public function testDeleteAllIndexesReturnsTasks(): void { $client = m::mock(Client::class); @@ -546,7 +673,7 @@ public function testDeleteAllIndexesReturnsTasks() $this->assertEquals([['taskUid' => 1], ['taskUid' => 2]], $tasks); } - public function testUpdateIndexSettingsWithEmbedders() + public function testUpdateIndexSettingsWithEmbedders(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -573,7 +700,7 @@ public function testUpdateIndexSettingsWithEmbedders() $this->assertTrue(true); } - public function testUpdateIndexSettingsWithoutEmbedders() + public function testUpdateIndexSettingsWithoutEmbedders(): void { $client = m::mock(Client::class); $index = m::mock(Indexes::class); @@ -597,7 +724,7 @@ public function testUpdateIndexSettingsWithoutEmbedders() $this->assertTrue(true); } - public function testConfigureSoftDeleteFilterAddsFilterableAttribute() + public function testConfigureSoftDeleteFilterAddsFilterableAttribute(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -610,7 +737,7 @@ public function testConfigureSoftDeleteFilterAddsFilterableAttribute() $this->assertContains('status', $settings['filterableAttributes']); } - public function testEngineForwardsCallsToMeilisearchClient() + public function testEngineForwardsCallsToMeilisearchClient(): void { $client = m::mock(Client::class); $client->shouldReceive('health') @@ -624,7 +751,7 @@ public function testEngineForwardsCallsToMeilisearchClient() $this->assertEquals(['status' => 'available'], $result); } - public function testGetMeilisearchClientReturnsClient() + public function testGetMeilisearchClientReturnsClient(): void { $client = m::mock(Client::class); $engine = new MeilisearchEngine($client); @@ -632,6 +759,14 @@ public function testGetMeilisearchClientReturnsClient() $this->assertSame($client, $engine->getMeilisearchClient()); } + protected function apiException(int $status, string $code): ApiException + { + return new ApiException(new Response($status), [ + 'message' => $code, + 'code' => $code, + ]); + } + protected function createSearchableModelMock(): m\MockInterface { return m::mock(Model::class . ', ' . SearchableInterface::class); @@ -668,3 +803,34 @@ class MeilisearchTestSoftDeleteModel extends Model implements SearchableInterfac public bool $timestamps = false; } + +class MeilisearchTestChirpModel extends Model +{ + protected ?string $table = 'chirps'; + + protected array $guarded = []; + + public bool $timestamps = false; + + public function getScoutKeyName(): string + { + return 'scout_id'; + } + + public function indexableAs(): string + { + return $this->getTable(); + } +} + +enum MeilisearchStringStatus: string +{ + case Draft = 'draft'; + case Published = 'published'; +} + +enum MeilisearchIntegerPriority: int +{ + case Low = 2; + case High = 1; +} diff --git a/tests/Scout/Unit/Engines/MeilisearchRetryMiddlewareTest.php b/tests/Scout/Unit/Engines/MeilisearchRetryMiddlewareTest.php index cfa5a5cc0..91394cab4 100644 --- a/tests/Scout/Unit/Engines/MeilisearchRetryMiddlewareTest.php +++ b/tests/Scout/Unit/Engines/MeilisearchRetryMiddlewareTest.php @@ -34,7 +34,7 @@ */ class MeilisearchRetryMiddlewareTest extends TestCase { - public function testEngineSmokeTestRetriesOn5xxThenSucceeds() + public function testEngineSmokeTestRetriesOn5xxThenSucceeds(): void { $mock = new MockHandler([ new Response(500), @@ -48,7 +48,7 @@ public function testEngineSmokeTestRetriesOn5xxThenSucceeds() $this->assertSame(0, $mock->count(), 'all queued responses consumed'); } - public function test500ThenSuccessRetriesOnce() + public function test500ThenSuccessRetriesOnce(): void { $mock = new MockHandler([ new Response(500), @@ -62,7 +62,7 @@ public function test500ThenSuccessRetriesOnce() $this->assertSame(0, $mock->count(), '2 attempts consumed'); } - public function test429ThenSuccessRetriesOnce() + public function test429ThenSuccessRetriesOnce(): void { $mock = new MockHandler([ new Response(429), @@ -76,7 +76,7 @@ public function test429ThenSuccessRetriesOnce() $this->assertSame(0, $mock->count(), '2 attempts consumed'); } - public function test400IsNotRetried() + public function test400IsNotRetried(): void { $mock = new MockHandler([ new Response(400), @@ -91,7 +91,7 @@ public function test400IsNotRetried() $this->assertSame(1, $mock->count(), 'only the first response was consumed (no retry)'); } - public function testConnectionExceptionThenSuccessRetriesOnce() + public function testConnectionExceptionThenSuccessRetriesOnce(): void { $request = new Psr7Request('POST', 'http://example.test/x'); $mock = new MockHandler([ @@ -106,7 +106,7 @@ public function testConnectionExceptionThenSuccessRetriesOnce() $this->assertSame(0, $mock->count(), '2 attempts consumed'); } - public function testRepeated5xxStopsAtConfiguredRetries() + public function testRepeated5xxStopsAtConfiguredRetries(): void { $mock = new MockHandler([ new Response(500), @@ -130,7 +130,7 @@ public function testRepeated5xxStopsAtConfiguredRetries() $this->assertSame(1, $mock->count(), '4 attempts consumed (1 initial + 3 retries); 5th response untouched'); } - public function testRetriesZeroDisablesRetrying() + public function testRetriesZeroDisablesRetrying(): void { $mock = new MockHandler([ new Response(500), @@ -150,7 +150,7 @@ public function testRetriesZeroDisablesRetrying() $this->assertSame(1, $mock->count(), 'only the first response was consumed (retries disabled)'); } - public function testHappyPathDoesNotRetryWhenFirstAttemptSucceeds() + public function testHappyPathDoesNotRetryWhenFirstAttemptSucceeds(): void { $mock = new MockHandler([ new Response(200), @@ -165,7 +165,7 @@ public function testHappyPathDoesNotRetryWhenFirstAttemptSucceeds() $this->assertSame(1, $mock->count(), 'only the first response was consumed (no retry needed)'); } - public function testCombinedRetryableConditionsAcrossAttempts() + public function testCombinedRetryableConditionsAcrossAttempts(): void { $request = new Psr7Request('POST', 'http://example.test/x'); $mock = new MockHandler([ @@ -182,7 +182,7 @@ public function testCombinedRetryableConditionsAcrossAttempts() $this->assertSame(0, $mock->count(), 'all 4 attempts consumed'); } - public function testRequestPreservedAcrossRetries() + public function testRequestPreservedAcrossRetries(): void { $container = []; $mock = new MockHandler([ diff --git a/tests/Scout/Unit/Engines/MeilisearchRetryPolicyTest.php b/tests/Scout/Unit/Engines/MeilisearchRetryPolicyTest.php index 7d7c53abe..a699b1b1a 100644 --- a/tests/Scout/Unit/Engines/MeilisearchRetryPolicyTest.php +++ b/tests/Scout/Unit/Engines/MeilisearchRetryPolicyTest.php @@ -13,14 +13,14 @@ class MeilisearchRetryPolicyTest extends TestCase { - public function testNextDelayMsReturnsBaseForFirstAttempt() + public function testNextDelayMsReturnsBaseForFirstAttempt(): void { $this->assertSame(100, MeilisearchRetryPolicy::nextDelayMs(1, 100)); $this->assertSame(50, MeilisearchRetryPolicy::nextDelayMs(1, 50)); $this->assertSame(250, MeilisearchRetryPolicy::nextDelayMs(1, 250)); } - public function testNextDelayMsDoublesEachAttempt() + public function testNextDelayMsDoublesEachAttempt(): void { $this->assertSame(100, MeilisearchRetryPolicy::nextDelayMs(1, 100)); $this->assertSame(200, MeilisearchRetryPolicy::nextDelayMs(2, 100)); @@ -28,21 +28,21 @@ public function testNextDelayMsDoublesEachAttempt() $this->assertSame(800, MeilisearchRetryPolicy::nextDelayMs(4, 100)); } - public function testNextDelayMsScalesWithAlternateBase() + public function testNextDelayMsScalesWithAlternateBase(): void { $this->assertSame(50, MeilisearchRetryPolicy::nextDelayMs(1, 50)); $this->assertSame(100, MeilisearchRetryPolicy::nextDelayMs(2, 50)); $this->assertSame(200, MeilisearchRetryPolicy::nextDelayMs(3, 50)); } - public function testShouldRetryOnConnectException() + public function testShouldRetryOnConnectException(): void { $exception = new ConnectException('Connection refused', new Request('POST', 'http://localhost')); $this->assertTrue(MeilisearchRetryPolicy::shouldRetry(null, $exception)); } - public function testShouldRetryOn5xxResponses() + public function testShouldRetryOn5xxResponses(): void { $this->assertTrue(MeilisearchRetryPolicy::shouldRetry(new Response(500), null)); $this->assertTrue(MeilisearchRetryPolicy::shouldRetry(new Response(502), null)); @@ -50,12 +50,12 @@ public function testShouldRetryOn5xxResponses() $this->assertTrue(MeilisearchRetryPolicy::shouldRetry(new Response(504), null)); } - public function testShouldRetryOn429Response() + public function testShouldRetryOn429Response(): void { $this->assertTrue(MeilisearchRetryPolicy::shouldRetry(new Response(429), null)); } - public function testShouldNotRetryOnSuccessfulResponses() + public function testShouldNotRetryOnSuccessfulResponses(): void { $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(200), null)); $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(201), null)); @@ -63,14 +63,14 @@ public function testShouldNotRetryOnSuccessfulResponses() $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(204), null)); } - public function testShouldNotRetryOnRedirectResponses() + public function testShouldNotRetryOnRedirectResponses(): void { $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(301), null)); $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(302), null)); $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(304), null)); } - public function testShouldNotRetryOnClientErrors() + public function testShouldNotRetryOnClientErrors(): void { $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(400), null)); $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(401), null)); @@ -79,14 +79,14 @@ public function testShouldNotRetryOnClientErrors() $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(new Response(422), null)); } - public function testShouldNotRetryOnUnrelatedException() + public function testShouldNotRetryOnUnrelatedException(): void { $exception = new RuntimeException('Something else went wrong'); $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(null, $exception)); } - public function testShouldNotRetryWhenBothResponseAndExceptionAreNull() + public function testShouldNotRetryWhenBothResponseAndExceptionAreNull(): void { $this->assertFalse(MeilisearchRetryPolicy::shouldRetry(null, null)); } diff --git a/tests/Scout/Unit/Engines/NullEngineTest.php b/tests/Scout/Unit/Engines/NullEngineTest.php index e1922114c..fcd148a75 100644 --- a/tests/Scout/Unit/Engines/NullEngineTest.php +++ b/tests/Scout/Unit/Engines/NullEngineTest.php @@ -15,7 +15,7 @@ class NullEngineTest extends TestCase { - public function testUpdateDoesNothing() + public function testUpdateDoesNothing(): void { $engine = new NullEngine; $models = new EloquentCollection([m::mock(Model::class)]); @@ -25,7 +25,7 @@ public function testUpdateDoesNothing() $this->assertTrue(true); } - public function testDeleteDoesNothing() + public function testDeleteDoesNothing(): void { $engine = new NullEngine; $models = new EloquentCollection([m::mock(Model::class)]); @@ -35,7 +35,7 @@ public function testDeleteDoesNothing() $this->assertTrue(true); } - public function testSearchReturnsEmptyArray() + public function testSearchReturnsEmptyArray(): void { $engine = new NullEngine; $builder = m::mock(Builder::class); @@ -46,7 +46,7 @@ public function testSearchReturnsEmptyArray() $this->assertEmpty($result); } - public function testPaginateReturnsEmptyArray() + public function testPaginateReturnsEmptyArray(): void { $engine = new NullEngine; $builder = m::mock(Builder::class); @@ -57,7 +57,7 @@ public function testPaginateReturnsEmptyArray() $this->assertEmpty($result); } - public function testMapIdsReturnsEmptyCollection() + public function testMapIdsReturnsEmptyCollection(): void { $engine = new NullEngine; @@ -67,7 +67,7 @@ public function testMapIdsReturnsEmptyCollection() $this->assertTrue($result->isEmpty()); } - public function testMapReturnsEmptyEloquentCollection() + public function testMapReturnsEmptyEloquentCollection(): void { $engine = new NullEngine; $builder = m::mock(Builder::class); @@ -79,7 +79,7 @@ public function testMapReturnsEmptyEloquentCollection() $this->assertTrue($result->isEmpty()); } - public function testLazyMapReturnsEmptyLazyCollection() + public function testLazyMapReturnsEmptyLazyCollection(): void { $engine = new NullEngine; $builder = m::mock(Builder::class); @@ -91,21 +91,21 @@ public function testLazyMapReturnsEmptyLazyCollection() $this->assertTrue($result->isEmpty()); } - public function testGetTotalCountReturnsZeroForEmptyResults() + public function testGetTotalCountReturnsZeroForEmptyResults(): void { $engine = new NullEngine; $this->assertSame(0, $engine->getTotalCount([])); } - public function testGetTotalCountReturnsCountForCountableResults() + public function testGetTotalCountReturnsCountForCountableResults(): void { $engine = new NullEngine; $this->assertSame(3, $engine->getTotalCount([1, 2, 3])); } - public function testFlushDoesNothing() + public function testFlushDoesNothing(): void { $engine = new NullEngine; $model = m::mock(Model::class); @@ -115,7 +115,7 @@ public function testFlushDoesNothing() $this->assertTrue(true); } - public function testCreateIndexReturnsEmptyArray() + public function testCreateIndexReturnsEmptyArray(): void { $engine = new NullEngine; @@ -125,7 +125,7 @@ public function testCreateIndexReturnsEmptyArray() $this->assertEmpty($result); } - public function testDeleteIndexReturnsEmptyArray() + public function testDeleteIndexReturnsEmptyArray(): void { $engine = new NullEngine; diff --git a/tests/Scout/Unit/Engines/TypesenseEngineTest.php b/tests/Scout/Unit/Engines/TypesenseEngineTest.php index 40e83c2d7..ec8c2954f 100644 --- a/tests/Scout/Unit/Engines/TypesenseEngineTest.php +++ b/tests/Scout/Unit/Engines/TypesenseEngineTest.php @@ -10,14 +10,19 @@ use Hypervel\Scout\Contracts\SearchableInterface; use Hypervel\Scout\Engines\TypesenseEngine; use Hypervel\Scout\Exceptions\NotSupportedException; +use Hypervel\Scout\Jobs\RemoveableScoutCollection; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use Mockery\MockInterface; use ReflectionMethod; +use Typesense\ApiCall; use Typesense\Client as TypesenseClient; use Typesense\Collection as TypesenseCollection; +use Typesense\Collections; use Typesense\Document; use Typesense\Documents; +use Typesense\Exceptions\ObjectAlreadyExists; use Typesense\Exceptions\ObjectNotFound; use Typesense\Exceptions\TypesenseClientError; @@ -30,19 +35,6 @@ protected function createEngine(?MockInterface $client = null): TypesenseEngine return new TypesenseEngine($client, 1000); } - /** - * Create a partial mock of the engine for testing methods that use getOrCreateCollectionFromModel. - */ - protected function createPartialEngine(?MockInterface $client = null): MockInterface&TypesenseEngine - { - $client = $client ?? m::mock(TypesenseClient::class); - - /** @var MockInterface&TypesenseEngine */ - return m::mock(TypesenseEngine::class, [$client, 1000]) - ->shouldAllowMockingProtectedMethods() - ->makePartial(); - } - protected function createSearchableModelMock(): MockInterface { return m::mock(Model::class . ', ' . SearchableInterface::class); @@ -61,11 +53,12 @@ public function testFiltersMethod(): void $builder = m::mock(Builder::class); $builder->wheres = [ - 'status' => 'active', - 'age' => 25, + ['field' => 'status', 'operator' => '=', 'value' => 'active'], + ['field' => 'age', 'operator' => '>=', 'value' => 25], + ['field' => 'priority', 'operator' => '!=', 'value' => TypesenseIntegerPriority::Low], ]; $builder->whereIns = [ - 'category' => ['electronics', 'books'], + 'category' => [TypesenseStringCategory::Electronics, TypesenseStringCategory::Books], ]; $builder->whereNotIns = [ 'brand' => ['apple', 'samsung'], @@ -74,7 +67,8 @@ public function testFiltersMethod(): void $result = $this->invokeMethod($engine, 'filters', [$builder]); $this->assertStringContainsString('status:=active', $result); - $this->assertStringContainsString('age:=25', $result); + $this->assertStringContainsString('age:>=25', $result); + $this->assertStringContainsString('priority:!=2', $result); $this->assertStringContainsString('category:=[electronics, books]', $result); $this->assertStringContainsString('brand:!=[apple, samsung]', $result); } @@ -88,6 +82,12 @@ public function testParseFilterValueMethod(): void $this->assertEquals(25, $this->invokeMethod($engine, 'parseFilterValue', [25])); $this->assertEquals(3.14, $this->invokeMethod($engine, 'parseFilterValue', [3.14])); $this->assertEquals('test', $this->invokeMethod($engine, 'parseFilterValue', ['test'])); + $this->assertEquals('books', $this->invokeMethod($engine, 'parseFilterValue', [TypesenseStringCategory::Books])); + $this->assertEquals(1, $this->invokeMethod($engine, 'parseFilterValue', [TypesenseIntegerPriority::High])); + $this->assertEquals( + ['electronics', 2], + $this->invokeMethod($engine, 'parseFilterValue', [[TypesenseStringCategory::Electronics, TypesenseIntegerPriority::Low]]) + ); } public function testParseWhereFilterMethod(): void @@ -96,6 +96,18 @@ public function testParseWhereFilterMethod(): void $this->assertEquals('status:=active', $this->invokeMethod($engine, 'parseWhereFilter', ['active', 'status'])); $this->assertEquals('age:=25', $this->invokeMethod($engine, 'parseWhereFilter', [25, 'age'])); + $this->assertEquals('age:>=25', $this->invokeMethod($engine, 'parseWhereFilter', [25, 'age', '>='])); + $this->assertEquals('status:!=inactive', $this->invokeMethod($engine, 'parseWhereFilter', ['inactive', 'status', '!='])); + } + + public function testParseWhereFilterRejectsUnsupportedOperators(): void + { + $engine = $this->createEngine(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported Typesense filter operator [LIKE].'); + + $this->invokeMethod($engine, 'parseWhereFilter', ['active', 'status', 'LIKE']); } public function testParseWhereInFilterMethod(): void @@ -193,17 +205,150 @@ public function testUpdateWithEmptyCollectionDoesNothing(): void $this->assertTrue(true); // No exception means success } + public function testUpdateTransformsBeforeTouchingTypesense(): void + { + $client = m::mock(TypesenseClient::class); + $client->shouldNotReceive('getCollections'); + + $model = new TypesenseLifecycleModel; + $model->searchableData = []; + + $this->createEngine($client)->update(new EloquentCollection([$model])); + } + + public function testUpdateImportsIntoIndexableCollection(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + + $client->shouldReceive('getCollections')->once()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + $collections->shouldNotReceive('create'); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); + $documents->shouldReceive('import') + ->once() + ->with([['id' => 1, 'title' => 'Scout']], ['action' => 'upsert']) + ->andReturn([['success' => true, 'document' => '{"id":1}']]); + + $model = new TypesenseLifecycleModel; + + $this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model])); + } + + public function testUpdateCreatesMissingCollectionAndRetriesImportOnce(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + + $client->shouldReceive('getCollections')->twice()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + $collections->shouldReceive('create') + ->once() + ->with([ + 'fields' => [['name' => 'title', 'type' => 'string']], + 'name' => 'write_index', + ]) + ->andReturn(['name' => 'write_index']); + $collection->shouldReceive('getDocuments')->twice()->andReturn($documents); + $documents->shouldReceive('import') + ->once() + ->ordered() + ->andThrow(new ObjectNotFound('Collection not found')); + $documents->shouldReceive('import') + ->once() + ->ordered() + ->andReturn([['success' => true, 'document' => '{"id":1}']]); + + $model = new TypesenseLifecycleModel; + + $this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model])); + } + + public function testUpdateAcceptsOnlyCollectionAlreadyExistsCreateRace(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + + $client->shouldReceive('getCollections')->twice()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + $collections->shouldReceive('create')->once()->andThrow(new ObjectAlreadyExists('Collection exists')); + $collection->shouldReceive('getDocuments')->twice()->andReturn($documents); + $documents->shouldReceive('import') + ->once() + ->ordered() + ->andThrow(new ObjectNotFound('Collection not found')); + $documents->shouldReceive('import') + ->once() + ->ordered() + ->andReturn([['success' => true, 'document' => '{"id":1}']]); + + $model = new TypesenseLifecycleModel; + + $this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model])); + } + + public function testUpdatePropagatesOtherCollectionCreateFailures(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + $exception = new TypesenseClientError('Invalid schema'); + + $client->shouldReceive('getCollections')->twice()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + $collections->shouldReceive('create')->once()->andThrow($exception); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); + $documents->shouldReceive('import')->once()->andThrow(new ObjectNotFound('Collection not found')); + + $model = new TypesenseLifecycleModel; + + $this->expectExceptionObject($exception); + + $this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model])); + } + + public function testUpdatePropagatesNonMissingImportFailure(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + $exception = new TypesenseClientError('Authentication failed'); + + $client->shouldReceive('getCollections')->once()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + $collections->shouldNotReceive('create'); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); + $documents->shouldReceive('import')->once()->andThrow($exception); + + $model = new TypesenseLifecycleModel; + + $this->expectExceptionObject($exception); + + $this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model])); + } + public function testDeleteRemovesDocumentsFromIndex(): void { $model = $this->createSearchableModelMock(); + $model->shouldReceive('indexableAs')->andReturn('write_index'); $model->shouldReceive('getScoutKey')->andReturn(123); - // Mock the Document object that's returned by array access on Documents $document = m::mock(Document::class); - $document->shouldReceive('retrieve')->once()->andReturn([]); $document->shouldReceive('delete')->once()->andReturn([]); - // Documents already implements ArrayAccess $documents = m::mock(Documents::class); $documents->shouldReceive('offsetGet') ->with('123') @@ -212,13 +357,38 @@ public function testDeleteRemovesDocumentsFromIndex(): void $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('getDocuments')->andReturn($documents); - $engine = $this->createPartialEngine(); - $engine->shouldReceive('getOrCreateCollectionFromModel') - ->once() - ->with($model, null, false) // Verify indexOperation=false to prevent collection creation - ->andReturn($collection); + $collections = m::mock(Collections::class); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + + $client = m::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->once()->andReturn($collections); + + $this->createEngine($client)->delete(new EloquentCollection([$model])); + } + + public function testDeleteUsesStoredScoutKeyFromRemoveableCollection(): void + { + $document = m::mock(Document::class); + $document->shouldReceive('delete')->once()->andReturn([]); + + $documents = m::mock(Documents::class); + $documents->shouldReceive('offsetGet')->with('stored-scout-key')->once()->andReturn($document); + + $collection = m::mock(TypesenseCollection::class); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); - $engine->delete(new EloquentCollection([$model])); + $collections = m::mock(Collections::class); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + + $client = m::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->once()->andReturn($collections); + + $model = new TypesenseLifecycleModel; + $model->setRawAttributes(['scout_id' => 'stored-scout-key']); + + $this->createEngine($client)->delete(new RemoveableScoutCollection([$model])); } public function testDeleteWithEmptyCollectionDoesNothing(): void @@ -238,10 +408,8 @@ public function testDeleteDocumentReturnsEmptyArrayWhenDocumentNotFound(): void $model = $this->createSearchableModelMock(); $model->shouldReceive('getScoutKey')->andReturn(123); - // Mock the Document object to throw ObjectNotFound on retrieve $document = m::mock(Document::class); - $document->shouldReceive('retrieve')->once()->andThrow(new ObjectNotFound('Document not found')); - $document->shouldNotReceive('delete'); + $document->shouldReceive('delete')->once()->andThrow(new ObjectNotFound('Document not found')); $documents = m::mock(Documents::class); $documents->shouldReceive('offsetGet') @@ -251,14 +419,16 @@ public function testDeleteDocumentReturnsEmptyArrayWhenDocumentNotFound(): void $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('getDocuments')->andReturn($documents); - $engine = $this->createPartialEngine(); - $engine->shouldReceive('getOrCreateCollectionFromModel') - ->once() - ->with($model, null, false) - ->andReturn($collection); + $model->shouldReceive('indexableAs')->andReturn('write_index'); - // Should not throw - idempotent delete - $engine->delete(new EloquentCollection([$model])); + $collections = m::mock(Collections::class); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + + $client = m::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->once()->andReturn($collections); + + $this->createEngine($client)->delete(new EloquentCollection([$model])); $this->assertTrue(true); } @@ -268,9 +438,8 @@ public function testDeleteDocumentThrowsOnNonNotFoundErrors(): void $model = $this->createSearchableModelMock(); $model->shouldReceive('getScoutKey')->andReturn(123); - // Mock the Document object to throw TypesenseClientError (network/auth error) $document = m::mock(Document::class); - $document->shouldReceive('retrieve')->once()->andThrow(new TypesenseClientError('Connection failed')); + $document->shouldReceive('delete')->once()->andThrow(new TypesenseClientError('Connection failed')); $documents = m::mock(Documents::class); $documents->shouldReceive('offsetGet') @@ -280,32 +449,56 @@ public function testDeleteDocumentThrowsOnNonNotFoundErrors(): void $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('getDocuments')->andReturn($documents); - $engine = $this->createPartialEngine(); - $engine->shouldReceive('getOrCreateCollectionFromModel') - ->once() - ->with($model, null, false) - ->andReturn($collection); + $model->shouldReceive('indexableAs')->andReturn('write_index'); + + $collections = m::mock(Collections::class); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + + $client = m::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->once()->andReturn($collections); $this->expectException(TypesenseClientError::class); $this->expectExceptionMessage('Connection failed'); - $engine->delete(new EloquentCollection([$model])); + $this->createEngine($client)->delete(new EloquentCollection([$model])); } public function testFlushDeletesCollection(): void { $model = $this->createSearchableModelMock(); + $model->shouldReceive('indexableAs')->andReturn('write_index'); $collection = m::mock(TypesenseCollection::class); $collection->shouldReceive('delete')->once(); - $engine = $this->createPartialEngine(); - $engine->shouldReceive('getOrCreateCollectionFromModel') - ->once() - ->with($model) - ->andReturn($collection); + $collections = m::mock(Collections::class); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + + $client = m::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->once()->andReturn($collections); + + $this->createEngine($client)->flush($model); + } + + public function testFlushTreatsMissingCollectionAsAlreadyFlushed(): void + { + $model = $this->createSearchableModelMock(); + $model->shouldReceive('indexableAs')->andReturn('write_index'); + + $collection = m::mock(TypesenseCollection::class); + $collection->shouldReceive('delete')->once()->andThrow(new ObjectNotFound('Collection not found')); + + $collections = m::mock(Collections::class); + $collections->shouldReceive('offsetGet')->with('write_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('write_index')->once(); + $collections->shouldNotReceive('create'); + + $client = m::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->once()->andReturn($collections); - $engine->flush($model); + $this->createEngine($client)->flush($model); } public function testDeleteIndexCallsTypesenseDelete(): void @@ -315,24 +508,12 @@ public function testDeleteIndexCallsTypesenseDelete(): void ->once() ->andReturn(['name' => 'test_index']); - // Create a test double that extends Collections to satisfy return type - $collections = new class($collection) extends \Typesense\Collections { - private $mockCollection; - - public function __construct($mockCollection) - { - // Don't call parent constructor - we're mocking - $this->mockCollection = $mockCollection; - } - - public function __get($name) - { - return $this->mockCollection; - } - }; + $collections = m::mock(Collections::class); + $collections->shouldReceive('offsetGet')->with('test_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('test_index')->once(); $client = m::mock(TypesenseClient::class); - $client->shouldReceive('getCollections')->andReturn($collections); + $client->shouldReceive('getCollections')->once()->andReturn($collections); $engine = $this->createEngine($client); @@ -341,6 +522,25 @@ public function __get($name) $this->assertEquals(['name' => 'test_index'], $result); } + public function testCollectionHandlesAreDetachedAndMagicNamesRemainUsable(): void + { + $collections = new Collections(m::mock(ApiCall::class)); + + $client = m::mock(TypesenseClient::class); + $client->shouldReceive('getCollections')->twice()->andReturn($collections); + + $engine = $this->createEngine($client); + + $first = $this->invokeMethod($engine, 'collection', ['first']); + $magicName = $this->invokeMethod($engine, 'collection', ['typesenseCollections']); + + $this->assertInstanceOf(TypesenseCollection::class, $first); + $this->assertInstanceOf(TypesenseCollection::class, $magicName); + $this->assertFalse(isset($collections['first'])); + $this->assertFalse(isset($collections['typesenseCollections'])); + $this->assertInstanceOf(Documents::class, $magicName->getDocuments()); + } + public function testGetTypesenseClientReturnsClient(): void { $client = m::mock(TypesenseClient::class); @@ -349,6 +549,88 @@ public function testGetTypesenseClientReturnsClient(): void $this->assertSame($client, $engine->getTypesenseClient()); } + public function testSearchUsesExplicitWithinCollection(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + + $client->shouldReceive('getCollections')->once()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('custom_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('custom_index')->once(); + $collections->shouldNotReceive('create'); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); + $documents->shouldReceive('search') + ->once() + ->with(m::on(fn (array $parameters): bool => $parameters['q'] === 'scout')) + ->andReturn(['found' => 0, 'hits' => []]); + + $model = new TypesenseLifecycleModel; + $builder = (new Builder($model, 'scout'))->within('custom_index'); + + $this->assertSame( + ['found' => 0, 'hits' => []], + $this->createPartialEngineWithConfig($client)->search($builder), + ); + } + + public function testSearchCreatesMissingSearchableCollectionAndRetriesOnce(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + + $client->shouldReceive('getCollections')->twice()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('read_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('read_index')->once(); + $collections->shouldReceive('create') + ->once() + ->with([ + 'fields' => [['name' => 'title', 'type' => 'string']], + 'name' => 'read_index', + ]) + ->andReturn(['name' => 'read_index']); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); + $documents->shouldReceive('search') + ->once() + ->ordered() + ->andThrow(new ObjectNotFound('Collection not found')); + $documents->shouldReceive('search') + ->once() + ->ordered() + ->andReturn(['found' => 0, 'hits' => []]); + + $model = new TypesenseLifecycleModel; + $builder = new Builder($model, 'scout'); + + $this->createPartialEngineWithConfig($client)->search($builder); + } + + public function testSearchPropagatesNonMissingFailure(): void + { + $client = m::mock(TypesenseClient::class); + $collections = m::mock(Collections::class); + $collection = m::mock(TypesenseCollection::class); + $documents = m::mock(Documents::class); + $exception = new TypesenseClientError('Authentication failed'); + + $client->shouldReceive('getCollections')->once()->andReturn($collections); + $collections->shouldReceive('offsetGet')->with('read_index')->once()->andReturn($collection); + $collections->shouldReceive('offsetUnset')->with('read_index')->once(); + $collections->shouldNotReceive('create'); + $collection->shouldReceive('getDocuments')->once()->andReturn($documents); + $documents->shouldReceive('search')->once()->andThrow($exception); + + $model = new TypesenseLifecycleModel; + $builder = new Builder($model, 'scout'); + + $this->expectExceptionObject($exception); + + $this->createPartialEngineWithConfig($client)->search($builder); + } + public function testMapReturnsEmptyCollectionWhenNoResults(): void { $engine = $this->createEngine(); @@ -414,7 +696,9 @@ public function testBuildSearchParametersIncludesFilters(): void $builder = m::mock(Builder::class); $builder->model = $model; $builder->query = 'test'; - $builder->wheres = ['status' => 'active']; + $builder->wheres = [ + ['field' => 'status', 'operator' => '=', 'value' => 'active'], + ]; $builder->whereIns = ['category' => ['a', 'b']]; $builder->whereNotIns = ['brand' => ['x']]; $builder->orders = []; @@ -565,3 +849,59 @@ protected function createPartialEngineWithConfig(?MockInterface $client = null): return $engine; } } + +class TypesenseLifecycleModel extends Model +{ + /** @var array */ + public array $searchableData = ['id' => 1, 'title' => 'Scout']; + + public function toSearchableArray(): array + { + return $this->searchableData; + } + + public function scoutMetadata(): array + { + return []; + } + + public function searchableAs(): string + { + return 'read_index'; + } + + public function indexableAs(): string + { + return 'write_index'; + } + + public function getScoutKey(): mixed + { + return $this->getAttribute($this->getScoutKeyName()); + } + + public function getScoutKeyName(): string + { + return 'scout_id'; + } + + public function typesenseCollectionSchema(): array + { + return [ + 'name' => 'stale_index', + 'fields' => [['name' => 'title', 'type' => 'string']], + ]; + } +} + +enum TypesenseStringCategory: string +{ + case Electronics = 'electronics'; + case Books = 'books'; +} + +enum TypesenseIntegerPriority: int +{ + case High = 1; + case Low = 2; +} diff --git a/tests/Scout/Unit/Jobs/MakeRangeSearchableTest.php b/tests/Scout/Unit/Jobs/MakeRangeSearchableTest.php index a2eb26447..02c3afec2 100644 --- a/tests/Scout/Unit/Jobs/MakeRangeSearchableTest.php +++ b/tests/Scout/Unit/Jobs/MakeRangeSearchableTest.php @@ -18,6 +18,13 @@ */ class MakeRangeSearchableTest extends ScoutTestCase { + public function testJobFailsOnTimeout(): void + { + $this->assertTrue( + (new MakeRangeSearchable(SearchableModel::class, 1, 2))->failOnTimeout + ); + } + public function testHandleDispatchesMakeSearchableForModelsInRange(): void { for ($i = 1; $i <= 5; ++$i) { diff --git a/tests/Scout/Unit/Jobs/MakeSearchableTest.php b/tests/Scout/Unit/Jobs/MakeSearchableTest.php new file mode 100644 index 000000000..1f2eb92bd --- /dev/null +++ b/tests/Scout/Unit/Jobs/MakeSearchableTest.php @@ -0,0 +1,151 @@ +set('scout.jobs', [ + 'tries' => 3, + 'backoff' => [1, 5, 10], + 'max_exceptions' => 2, + ]); + + $job = new MakeSearchable(Collection::make([$this->model(1)])); + + $this->assertSame(3, $job->tries); + $this->assertSame([1, 5, 10], $job->backoff); + $this->assertSame(2, $job->maxExceptions); + } + + public function testJobPropertiesAreNotSetWithoutConfig(): void + { + $job = new MakeSearchable(Collection::make([$this->model(1)])); + + $this->assertNull($job->tries); + $this->assertNull($job->backoff); + $this->assertNull($job->maxExceptions); + } + + public function testSubclassJobPropertiesAreNotOverriddenByConfig(): void + { + config()->set('scout.jobs', [ + 'tries' => 1, + 'backoff' => [1, 5, 10], + 'max_exceptions' => 1, + ]); + + $job = new OverriddenMakeSearchable(Collection::make([$this->model(1)])); + + $this->assertSame(5, $job->tries); + $this->assertSame([2, 4, 8, 16, 32], $job->backoff()); + $this->assertSame(3, $job->maxExceptions); + } + + public function testJobFailsOnTimeoutByDefault(): void + { + $job = new MakeSearchable(Collection::make([$this->model(1)])); + + $this->assertTrue($job->failOnTimeout); + } + + public function testSubclassCanOptOutOfFailingOnTimeout(): void + { + $job = new OverriddenMakeSearchable(Collection::make([$this->model(1)])); + + $this->assertFalse($job->failOnTimeout); + } + + public function testUniqueIdIsBasedOnTheClassAndScoutKeys(): void + { + $models = Collection::make([$this->model(2), $this->model(1)]); + + $expected = hash('sha256', json_encode([ + SearchableModel::class, + [1, 2], + ], JSON_THROW_ON_ERROR)); + + $job = new MakeSearchableUniquely($models); + + $this->assertInstanceOf(ShouldBeUniqueUntilProcessing::class, $job); + $this->assertSame(3600, $job->uniqueFor); + $this->assertSame($expected, $job->uniqueId()); + } + + public function testUniqueIdIsNotAffectedByModelOrder(): void + { + $models = Collection::make([$this->model(3), $this->model(1), $this->model(2)]); + + $this->assertSame( + (new MakeSearchableUniquely($models))->uniqueId(), + (new MakeSearchableUniquely($models->reverse()->values()))->uniqueId() + ); + } + + public function testUniqueIdDiffersForDifferentModelClasses(): void + { + $first = Collection::make([$this->model(1)]); + $second = Collection::make([(new OtherUniqueSearchableModel)->setAttribute('id', 1)]); + + $this->assertNotSame( + (new MakeSearchableUniquely($first))->uniqueId(), + (new MakeSearchableUniquely($second))->uniqueId() + ); + } + + public function testInvalidScoutKeyJsonFailsLoudly(): void + { + $models = Collection::make([(new InvalidUniqueSearchableModel)->setAttribute('id', 1)]); + + $this->expectException(JsonException::class); + + (new MakeSearchableUniquely($models))->uniqueId(); + } + + private function model(int $id): SearchableModel + { + return (new SearchableModel)->setAttribute('id', $id); + } +} + +class OverriddenMakeSearchable extends MakeSearchable +{ + public $tries = 5; + + public $maxExceptions = 3; + + public $failOnTimeout = false; + + /** + * Calculate the number of seconds to wait before retrying the job. + * + * @return list + */ + public function backoff(): array + { + return [2, 4, 8, 16, 32]; + } +} + +class OtherUniqueSearchableModel extends SearchableModel +{ +} + +class InvalidUniqueSearchableModel extends SearchableModel +{ + public function getScoutKey(): mixed + { + return "\xB1\x31"; + } +} diff --git a/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php index def5fc0e9..87ba8f48c 100644 --- a/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php +++ b/tests/Scout/Unit/Jobs/RemoveFromSearchTest.php @@ -4,9 +4,17 @@ namespace Hypervel\Tests\Scout\Unit\Jobs; +use Hypervel\Contracts\Database\ModelIdentifier; +use Hypervel\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Scout\EngineManager; +use Hypervel\Scout\Engines\Engine; use Hypervel\Scout\Jobs\RemoveableScoutCollection; use Hypervel\Scout\Jobs\RemoveFromSearch; +use Hypervel\Scout\Jobs\RemoveFromSearchUniquely; +use Hypervel\Support\Facades\DB; +use Hypervel\Tests\Scout\Models\CustomScoutKeyModel; use Hypervel\Tests\Scout\Models\SearchableModel; use Hypervel\Tests\Scout\ScoutTestCase; use Mockery as m; @@ -26,7 +34,7 @@ public function testHandleCallsEngineDelete(): void $collection = new Collection([$model1, $model2]); - $engine = m::mock(\Hypervel\Scout\Engines\Engine::class); + $engine = m::mock(Engine::class); $engine->shouldReceive('delete') ->once() ->with(m::on(function ($models) { @@ -34,12 +42,12 @@ public function testHandleCallsEngineDelete(): void && $models->count() === 2; })); - $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { - public function __construct(private $engine) + $this->app->instance(EngineManager::class, new class($engine) { + public function __construct(private Engine $engine) { } - public function engine(): \Hypervel\Scout\Engines\Engine + public function engine(): Engine { return $this->engine; } @@ -53,15 +61,15 @@ public function testHandleDoesNothingForEmptyCollection(): void { $collection = new Collection([]); - $engine = m::mock(\Hypervel\Scout\Engines\Engine::class); + $engine = m::mock(Engine::class); $engine->shouldNotReceive('delete'); - $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { - public function __construct(private $engine) + $this->app->instance(EngineManager::class, new class($engine) { + public function __construct(private Engine $engine) { } - public function engine(): \Hypervel\Scout\Engines\Engine + public function engine(): Engine { return $this->engine; } @@ -86,4 +94,123 @@ public function testConstructorWrapsCollectionInRemoveableScoutCollection(): voi $this->assertInstanceOf(RemoveableScoutCollection::class, $job->models); $this->assertCount(1, $job->models); } + + public function testModelsAreRestoredWithoutQueryingTheDatabase(): void + { + $model = SearchableModel::create(['title' => 'Removed', 'body' => 'Content']); + $job = new RemoveFromSearch(Collection::make([$model])); + + SearchableModel::query()->whereKey($model->getKey())->delete(); + + $connection = DB::connection(); + $connection->flushQueryLog(); + $connection->enableQueryLog(); + + /** @var RemoveFromSearch $restored */ + $restored = unserialize(serialize($job)); + + $this->assertSame([], $connection->getQueryLog()); + $this->assertInstanceOf(SearchableModel::class, $restored->models->first()); + $this->assertSame($model->getScoutKey(), $restored->models->first()->getAttribute('id')); + } + + public function testCustomScoutKeyAndConnectionAreRestoredExactly(): void + { + $model = (new CustomScoutKeyModel)->setConnection('archive'); + $model->id = 42; + + /** @var RemoveFromSearch $restored */ + $restored = unserialize(serialize(new RemoveFromSearch(Collection::make([$model])))); + $restoredModel = $restored->models->first(); + + $this->assertInstanceOf(CustomScoutKeyModel::class, $restoredModel); + $this->assertSame('archive', $restoredModel->getConnectionName()); + $this->assertSame('string', $restoredModel->getKeyType()); + $this->assertSame('custom-key-42', $restoredModel->getAttribute('id')); + } + + public function testMorphAliasesAreResolvedWhileRestoringModels(): void + { + Relation::morphMap(['searchable' => SearchableModel::class]); + ModelIdentifier::useMorphMap(); + + $model = new SearchableModel; + $model->id = 1; + + /** @var RemoveFromSearch $restored */ + $restored = unserialize(serialize(new RemoveFromSearch(Collection::make([$model])))); + + $this->assertInstanceOf(SearchableModel::class, $restored->models->first()); + $this->assertSame(1, $restored->models->first()->getAttribute('id')); + } + + public function testJobPropertiesAreSetFromConfig(): void + { + config()->set('scout.jobs', [ + 'tries' => 3, + 'backoff' => [1, 5, 10], + 'max_exceptions' => 2, + ]); + + $job = new RemoveFromSearch(Collection::make([$this->model(1)])); + + $this->assertSame(3, $job->tries); + $this->assertSame([1, 5, 10], $job->backoff); + $this->assertSame(2, $job->maxExceptions); + $this->assertTrue($job->failOnTimeout); + } + + public function testSubclassJobPropertiesAreNotOverriddenByConfig(): void + { + config()->set('scout.jobs', [ + 'tries' => 1, + 'backoff' => [1, 5, 10], + 'max_exceptions' => 1, + ]); + + $job = new OverriddenRemoveFromSearch(Collection::make([$this->model(1)])); + + $this->assertSame(5, $job->tries); + $this->assertSame([2, 4, 8, 16, 32], $job->backoff()); + $this->assertSame(3, $job->maxExceptions); + $this->assertFalse($job->failOnTimeout); + } + + public function testUniqueIdUsesSortedScoutKeys(): void + { + $models = Collection::make([$this->model(2), $this->model(1)]); + + $job = new RemoveFromSearchUniquely($models); + + $this->assertInstanceOf(ShouldBeUniqueUntilProcessing::class, $job); + $this->assertSame(3600, $job->uniqueFor); + $this->assertSame( + (new RemoveFromSearchUniquely($models->reverse()->values()))->uniqueId(), + $job->uniqueId() + ); + } + + private function model(int $id): SearchableModel + { + return (new SearchableModel)->setAttribute('id', $id); + } +} + +class OverriddenRemoveFromSearch extends RemoveFromSearch +{ + public $tries = 5; + + public $maxExceptions = 3; + + public $failOnTimeout = false; + + /** + * Calculate the number of seconds to wait before retrying the job. + * + * @return list + */ + public function backoff(): array + { + return [2, 4, 8, 16, 32]; + } } diff --git a/tests/Scout/Unit/Jobs/RemoveableScoutCollectionTest.php b/tests/Scout/Unit/Jobs/RemoveableScoutCollectionTest.php index f1b9ba569..461edea87 100644 --- a/tests/Scout/Unit/Jobs/RemoveableScoutCollectionTest.php +++ b/tests/Scout/Unit/Jobs/RemoveableScoutCollectionTest.php @@ -42,9 +42,9 @@ public function testGetQueueableIdsResolvesCustomScoutKeys(): void $collection = RemoveableScoutCollection::make([$model1, $model2, $model3]); $this->assertEquals([ - 'custom-key.1', - 'custom-key.2', - 'custom-key.3', + 'custom-key-1', + 'custom-key-2', + 'custom-key-3', ], $collection->getQueueableIds()); } @@ -70,7 +70,7 @@ public function testGetQueueableIdsWithMixedModels(): void $ids1 = $collection1->getQueueableIds(); // Both models use Searchable trait, so getScoutKey() is called on each - $this->assertEquals([100, 'custom-key.200'], $ids1); + $this->assertEquals([100, 'custom-key-200'], $ids1); } public function testNonSearchableFallbackPreservesCustomQueueableIds(): void diff --git a/tests/Scout/Unit/MakeSearchableUsingTest.php b/tests/Scout/Unit/MakeSearchableUsingTest.php index b847b279a..0cb76e034 100644 --- a/tests/Scout/Unit/MakeSearchableUsingTest.php +++ b/tests/Scout/Unit/MakeSearchableUsingTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Scout\Unit; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Scout\EngineManager; +use Hypervel\Scout\Engines\Engine; use Hypervel\Scout\Jobs\MakeSearchable; use Hypervel\Tests\Scout\Models\FilteringSearchableModel; use Hypervel\Tests\Scout\Models\SearchableModel; @@ -28,7 +30,7 @@ public function testSyncMakeSearchablePassesFilteredCollectionToEngine(): void $collection = new Collection([$published, $draft]); // Mock the engine to verify what gets passed to update() - $engine = m::mock(\Hypervel\Scout\Engines\Engine::class); + $engine = m::mock(Engine::class); $engine->shouldReceive('update') ->once() ->with(m::on(function ($models) { @@ -39,12 +41,12 @@ public function testSyncMakeSearchablePassesFilteredCollectionToEngine(): void })); // Replace the engine - $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { - public function __construct(private $engine) + $this->app->instance(EngineManager::class, new class($engine) { + public function __construct(private Engine $engine) { } - public function engine(): \Hypervel\Scout\Engines\Engine + public function engine(): Engine { return $this->engine; } @@ -65,15 +67,15 @@ public function testSyncMakeSearchableHandlesEmptyFilteredCollection(): void $collection = new Collection([$draft1, $draft2]); // Mock the engine - update should NOT be called - $engine = m::mock(\Hypervel\Scout\Engines\Engine::class); + $engine = m::mock(Engine::class); $engine->shouldNotReceive('update'); - $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { - public function __construct(private $engine) + $this->app->instance(EngineManager::class, new class($engine) { + public function __construct(private Engine $engine) { } - public function engine(): \Hypervel\Scout\Engines\Engine + public function engine(): Engine { return $this->engine; } @@ -98,7 +100,7 @@ public function testMakeSearchableJobPassesFilteredCollectionToEngine(): void $collection = new Collection([$published, $draft]); // Mock the engine to verify what gets passed to update() - $engine = m::mock(\Hypervel\Scout\Engines\Engine::class); + $engine = m::mock(Engine::class); $engine->shouldReceive('update') ->once() ->with(m::on(function ($models) { @@ -108,12 +110,12 @@ public function testMakeSearchableJobPassesFilteredCollectionToEngine(): void && $models->first()->title === 'Published Post'; })); - $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { - public function __construct(private $engine) + $this->app->instance(EngineManager::class, new class($engine) { + public function __construct(private Engine $engine) { } - public function engine(): \Hypervel\Scout\Engines\Engine + public function engine(): Engine { return $this->engine; } @@ -135,15 +137,15 @@ public function testMakeSearchableJobHandlesEmptyFilteredCollection(): void $collection = new Collection([$draft1, $draft2]); // Mock the engine - update should NOT be called - $engine = m::mock(\Hypervel\Scout\Engines\Engine::class); + $engine = m::mock(Engine::class); $engine->shouldNotReceive('update'); - $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { - public function __construct(private $engine) + $this->app->instance(EngineManager::class, new class($engine) { + public function __construct(private Engine $engine) { } - public function engine(): \Hypervel\Scout\Engines\Engine + public function engine(): Engine { return $this->engine; } @@ -170,19 +172,19 @@ public function testMakeSearchableUsingDefaultBehaviorPassesThroughUnchanged(): $collection = new Collection([$model1, $model2]); // Mock the engine to verify all models are passed - $engine = m::mock(\Hypervel\Scout\Engines\Engine::class); + $engine = m::mock(Engine::class); $engine->shouldReceive('update') ->once() ->with(m::on(function ($models) { return $models->count() === 2; })); - $this->app->instance(\Hypervel\Scout\EngineManager::class, new class($engine) { - public function __construct(private $engine) + $this->app->instance(EngineManager::class, new class($engine) { + public function __construct(private Engine $engine) { } - public function engine(): \Hypervel\Scout\Engines\Engine + public function engine(): Engine { return $this->engine; } diff --git a/tests/Scout/Unit/ModelObserverTest.php b/tests/Scout/Unit/ModelObserverTest.php new file mode 100644 index 000000000..c591859ca --- /dev/null +++ b/tests/Scout/Unit/ModelObserverTest.php @@ -0,0 +1,34 @@ +shouldReceive('wasSearchableBeforeDelete')->once()->andReturnTrue(); + $model->shouldReceive('isForceDeleting')->once()->andReturnTrue(); + $model->shouldReceive('unsearchable')->once(); + + $observer = (new ReflectionClass(ModelObserver::class))->newInstanceWithoutConstructor(); + + $observer->deleted($model); + $observer->forceDeleted($model); + } +} + +class ModelObserverSoftDeleteModel extends Model +{ + use SoftDeletes; +} diff --git a/tests/Scout/Unit/QueueDispatchTest.php b/tests/Scout/Unit/QueueDispatchTest.php index 894f57753..eb900ec31 100644 --- a/tests/Scout/Unit/QueueDispatchTest.php +++ b/tests/Scout/Unit/QueueDispatchTest.php @@ -20,7 +20,7 @@ class QueueDispatchTest extends ScoutTestCase public function testQueueMakeSearchableDispatchesJobWhenQueueEnabled(): void { $this->app->make('config')->set('scout.queue.enabled', true); - $this->app->make('config')->set('scout.queue.after_commit', false); + $this->app->make('config')->set('scout.after_commit', false); Bus::fake([MakeSearchable::class]); @@ -38,7 +38,7 @@ public function testQueueMakeSearchableDispatchesJobWhenQueueEnabled(): void public function testQueueMakeSearchableDispatchesWithAfterCommitWhenEnabled(): void { $this->app->make('config')->set('scout.queue.enabled', true); - $this->app->make('config')->set('scout.queue.after_commit', true); + $this->app->make('config')->set('scout.after_commit', true); Bus::fake([MakeSearchable::class]); @@ -55,7 +55,7 @@ public function testQueueMakeSearchableDispatchesWithAfterCommitWhenEnabled(): v public function testQueueRemoveFromSearchDispatchesJobWhenQueueEnabled(): void { $this->app->make('config')->set('scout.queue.enabled', true); - $this->app->make('config')->set('scout.queue.after_commit', false); + $this->app->make('config')->set('scout.after_commit', false); Bus::fake([RemoveFromSearch::class]); @@ -73,7 +73,7 @@ public function testQueueRemoveFromSearchDispatchesJobWhenQueueEnabled(): void public function testQueueRemoveFromSearchDispatchesWithAfterCommitWhenEnabled(): void { $this->app->make('config')->set('scout.queue.enabled', true); - $this->app->make('config')->set('scout.queue.after_commit', true); + $this->app->make('config')->set('scout.after_commit', true); Bus::fake([RemoveFromSearch::class]); diff --git a/tests/Scout/Unit/ScoutServiceProviderTest.php b/tests/Scout/Unit/ScoutServiceProviderTest.php index 52c3e291d..b1a0df3bf 100644 --- a/tests/Scout/Unit/ScoutServiceProviderTest.php +++ b/tests/Scout/Unit/ScoutServiceProviderTest.php @@ -30,7 +30,7 @@ protected function getPackageProviders(Application $app): array return [ScoutServiceProvider::class]; } - public function testTypesenseClientUsesGuzzle() + public function testTypesenseClientUsesGuzzle(): void { $this->app->make('config')->set('scout.typesense.client-settings', [ 'api_key' => 'test-key', @@ -59,7 +59,7 @@ public function testTypesenseClientUsesGuzzle() } } - public function testTypesenseClientRespectsExplicitClientConfig() + public function testTypesenseClientRespectsExplicitClientConfig(): void { $customClient = new GuzzleClient(['timeout' => 99]); @@ -84,7 +84,7 @@ public function testTypesenseClientRespectsExplicitClientConfig() $this->assertSame($customClient, $client); } - public function testMeilisearchClientUsesExplicitGuzzle() + public function testMeilisearchClientUsesExplicitGuzzle(): void { // Closes the pre-existing gap where the binding fell through to // Psr18ClientDiscovery::find(). Verifies our explicit Guzzle injection @@ -100,7 +100,7 @@ public function testMeilisearchClientUsesExplicitGuzzle() $this->assertInstanceOf(GuzzleClient::class, $psr18); } - public function testAlgoliaClientIsRegistered() + public function testAlgoliaClientIsRegistered(): void { // Algolia4SearchConfig throws if appId or apiKey is empty, so seed // non-empty credentials before resolving. @@ -113,7 +113,7 @@ public function testAlgoliaClientIsRegistered() $this->assertInstanceOf(AlgoliaSearchClient::class, $client); } - public function testAlgoliaSdkUsesExplicitGuzzleAfterProviderBoot() + public function testAlgoliaSdkUsesExplicitGuzzleAfterProviderBoot(): void { $wrapper = Algolia::getHttpClient(); @@ -128,7 +128,7 @@ public function testAlgoliaSdkUsesExplicitGuzzleAfterProviderBoot() ); } - public function testAlgoliaClientUsesExplicitGuzzle() + public function testAlgoliaClientUsesExplicitGuzzle(): void { // Pins the behaviour: provider boot configures the Algolia SDK to use // our Guzzle-backed HTTP client before the client binding is resolved. @@ -148,7 +148,7 @@ public function testAlgoliaClientUsesExplicitGuzzle() $this->assertInstanceOf(GuzzleClient::class, $inner); } - public function testAlgoliaClientHasScoutTelescopeTags() + public function testAlgoliaClientHasScoutTelescopeTags(): void { $this->app->make('config')->set('scout.algolia.id', 'test-app-id'); $this->app->make('config')->set('scout.algolia.secret', 'test-secret'); @@ -165,7 +165,7 @@ public function testAlgoliaClientHasScoutTelescopeTags() ); } - public function testMeilisearchClientHasScoutTelescopeTags() + public function testMeilisearchClientHasScoutTelescopeTags(): void { /** @var MeilisearchClient $client */ $client = $this->app->make(MeilisearchClient::class); @@ -247,7 +247,7 @@ public function testMeilisearchClientOmitsRetryMiddlewareWhenRetriesDisabled(): $this->assertSame(1, $mock->count(), 'only the first response should be consumed when retries are disabled'); } - public function testTypesenseClientHasScoutTelescopeTags() + public function testTypesenseClientHasScoutTelescopeTags(): void { $this->app->make('config')->set('scout.typesense.client-settings', [ 'api_key' => 'test-key', diff --git a/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php b/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php index e54af7107..4457026c3 100644 --- a/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php +++ b/tests/Scout/migrations/2025_01_01_000000_create_searchable_models_table.php @@ -23,5 +23,23 @@ public function up(): void $table->timestamps(); $table->softDeletes(); }); + + Schema::create('scout_through_owners', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + + Schema::create('scout_through_intermediates', function (Blueprint $table) { + $table->id(); + $table->foreignId('owner_id'); + $table->timestamps(); + }); + + Schema::create('scout_through_models', function (Blueprint $table) { + $table->id(); + $table->foreignId('intermediate_id'); + $table->string('title'); + $table->timestamps(); + }); } }; diff --git a/tests/Support/MeilisearchIntegrationTestCase.php b/tests/Support/MeilisearchIntegrationTestCase.php index 789923968..54add59e9 100644 --- a/tests/Support/MeilisearchIntegrationTestCase.php +++ b/tests/Support/MeilisearchIntegrationTestCase.php @@ -8,7 +8,6 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithMeilisearch; use Hypervel\Scout\ScoutServiceProvider; use Hypervel\Testbench\TestCase; -use Throwable; /** * Base test case for Meilisearch integration tests. @@ -101,21 +100,4 @@ protected function createTestIndex(string $name, array $options = []): void $indexName = $this->prefixedIndexName($name); $this->meilisearch->createIndex($indexName, $options); } - - /** - * Wait for Meilisearch tasks to complete. - */ - protected function waitForTasks(int $timeoutMs = 5000): void - { - try { - $tasks = $this->meilisearch->getTasks(); - foreach ($tasks->getResults() as $task) { - if (in_array($task['status'], ['enqueued', 'processing'], true)) { - $this->meilisearch->waitForTask($task['uid'], $timeoutMs); - } - } - } catch (Throwable) { - // Ignore timeout errors - } - } }