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 400d73960..148028981 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
@@ -981,7 +981,7 @@ Append package entries in checklist order. Keep each entry compact but complete
| `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 |
+| `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 and require a published terminal result 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.
@@ -1563,7 +1563,7 @@ Append package entries in checklist order. Keep each entry compact but complete
| `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. |
+| `foundation-18` | Make Foundation's Algolia cleanup wait for each exact index-deletion task and require a published terminal result 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.
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
index ebc5d7e40..80accc919 100644
--- 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
@@ -133,7 +133,7 @@ Apart from the approved `database-21` behavior correction, no accepted item adds
| `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. |
+| `foundation-18` | Integration-test harness defect | Major | Foundation's Algolia cleanup waits for each exact index-deletion task and requires a published terminal result before setup or teardown continues. |
## Implementation design
@@ -321,9 +321,9 @@ The two existing `DatabaseQueryBuilderTest` methods retain their current signatu
### 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.
+Use Foundation's `InteractsWithMeilisearch::waitForMeilisearchTasks()` as the single wait owner. Select pending tasks whose index UID begins with the current test worker's prefix, 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` and requires a published terminal result because its SDK can swallow a polling failure and return null. 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.
+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, or add a task watermark.
## Test plan
diff --git a/docs/plans/2026-08-02-1749-scout-lifecycle-and-filter-maintenance-apis.md b/docs/plans/2026-08-02-1749-scout-lifecycle-and-filter-maintenance-apis.md
new file mode 100644
index 000000000..fd8d32c4b
--- /dev/null
+++ b/docs/plans/2026-08-02-1749-scout-lifecycle-and-filter-maintenance-apis.md
@@ -0,0 +1,171 @@
+# Scout Lifecycle and Filter Maintenance APIs
+
+## Status and scope
+
+**Status:** Complete; implementation, validation, self-review, and independent code review are signed off.
+
+Add the narrow Scout lifecycle boundaries and engine capabilities required by framework integrations without wrapping engines or duplicating driver filter syntax. Preserve every current Laravel Scout call shape. The only intentional additive signature difference is `Searchable::removeAllFromSearch(bool $force = false)`, whose default preserves existing behavior.
+
+This work owns:
+
+- Builder, document, index-settings, and model-flush lifecycle callbacks;
+- external-engine composition of raw application filters with Builder filters;
+- completion-aware filter deletion on engines that support it;
+- explicit local Meilisearch tenant-token signing;
+- Foundation's Algolia cleanup completion check;
+- unit, feature, real-engine integration coverage, and public Scout documentation.
+
+Typesense custom-`within()` recovery is already correct and needs no change.
+
+## Design rules
+
+- Keep Laravel public APIs, names, named arguments, and extension points unless the approved additive force argument applies.
+- Store each lifecycle callback in one nullable static `Closure` slot on `Scout`; registration is boot-only and replacement-based, never cumulative.
+- Reset every slot through `Scout::flushState()`, which the test-state subscriber already owns.
+- Keep filter compilers and SDK/task semantics inside their engines.
+- Add no callback registry, policy object, engine wrapper, shared filter compiler, retry, lock, cache, or per-tenant engine state.
+- Direct Engine/SDK access and low-level search callbacks remain raw boundaries, except for the explicit `DeletesByFilter` capability, which is a prepared Builder terminal.
+
+## Lifecycle callbacks
+
+`Scout` exposes four boot-only registration methods and public lifecycle invokers:
+
+```php
+Scout::prepareBuilderUsing(callable $callback): void;
+Scout::prepareBuilder(Builder $builder, Engine $engine): void;
+
+Scout::prepareSearchableDocumentUsing(callable $callback): void;
+Scout::prepareSearchableDocument(array $document, Model $model, Engine $engine): array;
+
+Scout::prepareIndexSettingsUsing(callable $callback): void;
+Scout::prepareIndexSettings(array $settings, ?Model $model, Engine $engine, string $index): array;
+
+Scout::guardModelFlushUsing(callable $callback): void;
+Scout::guardModelFlush(Model $model, Engine $engine, bool $force): void;
+```
+
+The callback contracts are:
+
+- `(Builder, Engine): void`;
+- `(array, Model, Engine): array`;
+- `(array, ?Model, Engine, string): array`;
+- `(Model, Engine, bool): void`, where throwing vetoes the flush.
+
+Registration methods warn that captured state persists for the worker lifetime. Registering again replaces the prior callback. Null slots preserve stock behavior. `Scout`'s class documentation distinguishes stable job-class state from boot-configured callback objects, and its repeated job defaults use protected constants shared by property initialization and `flushState()`.
+
+### Builder boundary
+
+Protected `Builder::preparedEngine()` resolves the selected engine, invokes `Scout::prepareBuilder()` once, and returns the engine. It is the first engine-resolution statement in every outer terminal:
+
+- `raw()`;
+- `keys()`;
+- `get()` and therefore `first()`;
+- `cursor()`;
+- `simplePaginate()`;
+- `paginate()`;
+- `paginateRaw()`;
+- `simplePaginateRaw()`.
+
+Pagination preparation precedes all engine-capability branches. `getTotalCount()` keeps pure `engine()` resolution so pagination does not prepare twice; its cloned Builder inherits the already-prepared constraints. Constructing or configuring a Builder performs no preparation.
+
+`deleteByFilter()` is also a prepared Builder terminal. Each implementation invokes `Scout::prepareBuilder()` before target resolution, compilation, validation, or I/O. The capability contract requires custom implementations to do the same.
+
+### Document boundary
+
+Algolia, Meilisearch, and Typesense pass each non-empty final external document through `Scout::prepareSearchableDocument()` after application data, Scout soft-delete metadata, and engine key metadata are assembled. Custom engines may invoke the same public boundary. Database, Collection, and Null engines remain unchanged.
+
+### Settings boundary
+
+`scout:index` (`IndexCommand`) invokes `Scout::prepareIndexSettings()` after application and soft-delete contributions and before its empty-settings decision. `scout:sync-index-settings` (`SyncIndexSettingsCommand`) invokes it after soft-delete contribution and physical index-name resolution, before the unconditional settings update. A raw named settings entry supplies `null` for the model. A callback may prepare an enumerated empty settings entry but does not invent indexes absent from the settings command's map.
+
+Typesense invokes the same boundary after the model schema is assembled during lazy collection creation. The callback receives settings without target identity plus the resolved index name separately; Scout writes the authoritative schema `name` afterward.
+
+### Flush boundary
+
+`Searchable::removeAllFromSearch(bool $force = false)` invokes the model-flush guard before `Engine::flush()`. Existing callers remain unforced. Only `scout:flush` passes `force: true`; `scout:import --fresh` remains unforced. Direct `Engine::flush()` and whole-index commands remain explicitly global raw operations.
+
+The Scout README records this actionable Laravel difference; Boost documentation explains the public behavior.
+
+## External filter composition
+
+External engines preserve both application/model raw filters and Builder constraints:
+
+- Algolia's search and pagination paths no longer add an engine-derived `filters` value to their merge input. `performSearch()` merges the remaining options, reads the surviving application `filters`, composes it once with the Builder expression as `(application) AND (builder)`, and writes the key only when the result is non-empty.
+- Meilisearch follows the same single-owner shape for `filter`. String sides use `(application) AND (builder)`. A documented array application filter is top-level-merged with the Builder string so outer AND and nested OR semantics remain intact.
+- Typesense initializes `filter_by` empty, applies model and Builder option merges, then composes the winning raw value with Builder constraints as `(application) && (builder)`.
+
+An empty side returns the other side. Exact-output tests prove every empty/non-empty combination is well formed, with no dangling or doubled conjunction. Caller-authored duplicate predicates remain legal; Scout adds no equality-deduplication branch. Search callbacks receive the final composed options. Existing engine boundaries remain responsible for invalid raw option types.
+
+## Filter deletion
+
+Add optional `Contracts\DeletesByFilter`:
+
+```php
+public function deleteByFilter(Builder $builder): void;
+```
+
+Algolia, Meilisearch, and Typesense implement it. Database, Collection, Null, and the base `Engine` do not pretend to support it.
+
+Each implementation:
+
+1. prepares the Builder;
+2. targets explicit `within()` when present, otherwise the writable `indexableAs()` target;
+3. compiles through the engine's existing Builder filter path and composition rules;
+4. rejects an empty effective filter before SDK I/O; and
+5. returns only after deletion succeeds or throws.
+
+An absent target index is a successful no-op. Algolia and Typesense recognize their driver's exact missing-index exception around the delete request. Meilisearch recognizes `index_not_found` only from the terminal task returned by its asynchronous delete; every other failed task retains the ordinary failure path. Whole-model `flush()` keeps each engine's existing semantics, including Meilisearch's asynchronous enqueue behavior.
+
+Completion semantics remain driver-owned:
+
+- Algolia calls `deleteBy()` and waits for the returned task. Its SDK can swallow a polling exception and return `null`, so Scout locally corrects the inaccurate vendor return contract and requires a non-null `published` task; an ordinary null/other-status return becomes `ScoutException`, while SDK-thrown failures retain their type;
+- Meilisearch calls filtered document deletion and waits up to 500 seconds through protected engine timeout and interval constants with a short bulk-operation WHY comment. A five-second interval matches Algolia's steady-state cadence, polls immediately once, and caps a full wait at 100 service requests instead of inheriting the SDK's interactive 50 ms cadence and issuing up to 10,000. A returned terminal task whose status is not `succeeded` becomes `ScoutException`; SDK timeout/transport exceptions propagate with their original type and cause;
+- Typesense performs synchronous filtered deletion.
+
+The contract returns `void`; the three services expose no truthful common deleted-count result. `Builder::within()` documentation describes its exact explicit target for searches and filter deletions, while the capability documents the default write target.
+
+## Meilisearch tenant tokens
+
+Correct the Hypervel-owned helper to sign locally with one explicitly matching parent-key identity:
+
+```php
+public function generateTenantToken(
+ array $searchRules,
+ string $apiKeyUid,
+ string $apiKey,
+ ?DateTimeInterface $expiresAt = null,
+): string;
+```
+
+Remove network key discovery. Reject empty UID and key before calling the SDK; the key guard is load-bearing because the SDK replaces an empty option with the client key. Delegate rule and past-expiry validation to the SDK. Pass both UID and key explicitly so the token cannot name one key while being signed by another.
+
+This method is not a Laravel API, had no callers or documentation, and its former optional discovery path could create invalid credentials. No compatibility shim remains.
+
+## Verification
+
+Focused tests cover:
+
+- every callback boundary, stock-null behavior, replacement, reset, and no invocation at Builder construction;
+- one Builder preparation per terminal, including all pagination capability branches and filter deletion;
+- document preparation after reserved metadata for all external engines;
+- settings preparation in both commands and Typesense lazy creation, including nullable model entries and authoritative Typesense names;
+- default/unforced model flush, forced command flush, and unforced import-fresh behavior;
+- string and Meilisearch-array filter composition across search, pagination, callbacks, escaping, option precedence, and every empty/non-empty side;
+- filter-delete target selection, empty refusal, missing-target no-op, SDK request shape, Algolia null/failed completion, Meilisearch failed/timeout completion, bounded polling, and Typesense synchronous completion;
+- explicit token inputs, no keys API call, empty guards, expiry delegation, and local signature construction.
+
+Existing external-service suites under `tests/Integration/Scout/{Algolia,Meilisearch,Typesense}` prove real composed searches and filter deletion. Meilisearch integration additionally creates a matching child key/token, proves matching credentials work, and proves a mismatched signer is rejected. Tests use the existing service traits and skip only when the service is unconfigured; a configured broken service fails.
+
+Meilisearch's shared integration-test task waiter considers only pending tasks whose index UID starts with the current worker's test prefix. This preserves the existing exact-task completion checks without allowing one ParaTest worker to adopt another worker's deliberately failed missing-index deletion.
+
+Foundation's shared `InteractsWithAlgolia` cleanup applies the same truthful completion check to every exact index-deletion task it already owns. A nullable/non-`published` normal return throws `RuntimeException` during setup instead of allowing the next test to race a pending deletion; teardown retains its existing exception-safe cleanup policy. Deterministic harness coverage pins the swallowed-null path. This completes the existing `foundation-18` contract without adding a Scout-local waiter.
+
+Update `src/boost/docs/scout.md` for the callbacks, filter deletion, explicit force, composed options, and tenant-token helper. Keep the package README concise and limited to actionable differences from Laravel, including the additive lifecycle/filter-deletion surface and explicit-force flush behavior. Removing tenant-token key discovery also removes its now-unused `RuntimeException` import.
+
+Run changed tests immediately, then Scout unit/feature and configured real-engine suites, followed by the authoritative `composer fix`, `git diff --check`, fresh caller/callee self-review, and independent code review through signoff.
+
+## Expected result
+
+Normal external searches add constant local filter composition and one null-checked lifecycle call before each terminal. Indexing/settings add one null-checked callback at their existing assembly boundary. Filter deletion adds only its requested service operation and required task wait. No per-document query, extra network lookup, per-tenant client, retained map, or request-time registration is introduced.
+
+Laravel search APIs continue to work unchanged. Framework integrations can enforce ambient search isolation, reserved document/settings metadata, global-flush policy, and tenant-selective purge through first-class Scout boundaries instead of engine wrappers or duplicated SDK logic.
diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist
index be73ed150..bc29860cc 100644
--- a/phpstan.types.neon.dist
+++ b/phpstan.types.neon.dist
@@ -1,4 +1,5 @@
parameters:
level: max
+ tmpDir: .cache/phpstan-types
paths:
- types
diff --git a/src/boost/docs/scout.md b/src/boost/docs/scout.md
index 036388530..6cf1d6474 100644
--- a/src/boost/docs/scout.md
+++ b/src/boost/docs/scout.md
@@ -7,6 +7,7 @@
- [Configuration](#configuration)
- [Configuring Searchable Data](#configuring-searchable-data)
- [Customizing the Scout Builder](#customizing-the-scout-builder)
+ - [Customizing Scout Lifecycles](#customizing-scout-lifecycles)
- [Database, Collection, and Null Engines](#database-and-collection-engines)
- [Database Engine](#database-engine)
- [Collection Engine](#collection-engine)
@@ -15,6 +16,7 @@
- [Configuring Model Indexes](#configuring-model-indexes)
- [Algolia](#algolia-configuration)
- [Meilisearch](#meilisearch-configuration)
+ - [Tenant Tokens](#meilisearch-tenant-tokens)
- [Typesense](#typesense-configuration)
- [Third-Party Engine Indexing](#indexing)
- [Batch Import](#batch-import)
@@ -25,6 +27,7 @@
- [Conditionally Searchable Model Instances](#conditionally-searchable-model-instances)
- [Searching](#searching)
- [Where Clauses](#where-clauses)
+ - [Combining Raw and Builder Filters](#combining-raw-and-builder-filters)
- [Pagination](#pagination)
- [Soft Deleting](#soft-deleting)
- [Customizing Engine Searches](#customizing-engine-searches)
@@ -285,6 +288,45 @@ 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.
+
+### Customizing Scout Lifecycles
+
+Packages may register callbacks that prepare Scout operations immediately before they reach a search engine. Register these callbacks from a service provider's `boot` method:
+
+```php
+use Hypervel\Database\Eloquent\Model;
+use Hypervel\Scout\Builder;
+use Hypervel\Scout\Engines\Engine;
+use Hypervel\Scout\Scout;
+use LogicException;
+
+Scout::prepareBuilderUsing(function (Builder $builder, Engine $engine): void {
+ $builder->where('account_id', currentAccountId());
+});
+
+Scout::prepareSearchableDocumentUsing(
+ fn (array $document, Model $model, Engine $engine): array => [
+ ...$document,
+ 'account_id' => $model->account_id,
+ ]
+);
+
+Scout::prepareIndexSettingsUsing(
+ fn (array $settings, ?Model $model, Engine $engine, string $index): array => $settings
+);
+
+Scout::guardModelFlushUsing(function (Model $model, Engine $engine, bool $force): void {
+ if (! $force) {
+ throw new LogicException('This index may only be flushed explicitly.');
+ }
+});
+```
+
+Registering a callback replaces the previously registered callback for that lifecycle. Builder preparation runs once before each terminal Scout search and before filtered deletion through `DeletesByFilter`. Document preparation receives the final document, including Scout metadata and its engine key. Settings preparation runs for `scout:index`, `scout:sync-index-settings`, and lazy Typesense collection creation.
+
+> [!WARNING]
+> Lifecycle callbacks persist for the worker lifetime. Register them only during application boot and do not capture request-scoped state. Arbitrary direct engine and search SDK calls remain low-level operations and do not invoke these callbacks automatically; `DeletesByFilter::deleteByFilter` is an explicit prepared Builder terminal.
+
## Database, Collection, and Null Engines
@@ -589,6 +631,35 @@ public function toSearchableArray()
}
```
+
+#### Tenant Tokens
+
+Meilisearch tenant tokens let browser clients search with an enforced filter without exposing a parent API key. Scout signs these tokens locally when you provide the matching parent key UID and secret:
+
+```php
+use DateTimeImmutable;
+use Hypervel\Scout\Engines\MeilisearchEngine;
+use Hypervel\Scout\Scout;
+use LogicException;
+
+$engine = Scout::engine('meilisearch');
+
+if (! $engine instanceof MeilisearchEngine) {
+ throw new LogicException('The Meilisearch engine is not configured.');
+}
+
+$token = $engine->generateTenantToken(
+ searchRules: [
+ 'orders' => ['filter' => 'account_id = 42'],
+ ],
+ apiKeyUid: $parentKeyUid,
+ apiKey: $parentKey,
+ expiresAt: new DateTimeImmutable('+1 hour'),
+);
+```
+
+The UID identifies the parent key while the key signs the token, so both values must describe the same Meilisearch key. Scout does not query the keys API while generating a token.
+
### Typesense
@@ -858,6 +929,33 @@ To remove all of the model records from their corresponding index, you may invok
Order::removeAllFromSearch();
```
+The optional `force` argument is passed to any registered [model-flush guard](#customizing-scout-lifecycles). Normal model calls and `scout:import --fresh` are unforced; the explicit `scout:flush` command passes `true`:
+
+```php
+Order::removeAllFromSearch(force: true);
+```
+
+Algolia, Meilisearch, and Typesense can also delete only the documents matched by a Scout Builder:
+
+```php
+use App\Models\Order;
+use Hypervel\Scout\Contracts\DeletesByFilter;
+use LogicException;
+
+$builder = Order::search('')->where('account_id', 42);
+$engine = (new Order)->searchableUsing();
+
+if (! $engine instanceof DeletesByFilter) {
+ throw new LogicException('The configured engine cannot delete by filter.');
+}
+
+$engine->deleteByFilter($builder);
+```
+
+Filtered deletion refuses an empty filter. It uses an explicit `within` index when provided and otherwise targets the model's writable `indexableAs` index. A missing target index is a successful no-op. The method returns only after the engine reports completion; SDK timeout and transport exceptions are not hidden.
+
+Since Algolia and Meilisearch perform these deletions asynchronously, Scout waits for them to finish before returning. Large deletions may take several minutes; Meilisearch waits up to 500 seconds and checks every five seconds, so long deletions should run in a queued job or console command instead of a web request. Typesense deletes synchronously.
+
### Pausing Indexing
@@ -1021,6 +1119,20 @@ $orders = Order::search('Star Trek')->whereNotIn(
> [!WARNING]
> If your application is using Meilisearch, you must configure your application's [filterable attributes](#meilisearch-index-settings) before utilizing Scout's "where" clauses.
+
+### Combining Raw and Builder Filters
+
+Algolia, Meilisearch, and Typesense preserve a raw application filter supplied through `options` when you also add Scout `where` clauses. Scout groups the application expression with its compiled Builder expression so neither side changes the other's precedence:
+
+```php
+Order::search('Star Trek')
+ ->options(['filters' => 'status:open OR status:paid']) // Algolia
+ ->where('account_id', 42)
+ ->get();
+```
+
+Use `filter` for Meilisearch and `filter_by` for Typesense. Meilisearch array filters retain their documented nested OR and outer AND structure; Scout appends its Builder expression as one additional outer AND term. Engine search callbacks receive the final composed options.
+
#### Customizing the Eloquent Results Query
diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php
index 5ad866a8f..2cbd08dbe 100644
--- a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php
+++ b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php
@@ -8,7 +8,9 @@
use Algolia\AlgoliaSearch\Api\SearchClient as AlgoliaSearchClient;
use Algolia\AlgoliaSearch\Http\GuzzleHttpClient;
use Algolia\AlgoliaSearch\Http\HttpClientInterface;
+use Algolia\AlgoliaSearch\Model\Search\GetTaskResponse;
use GuzzleHttp\Client as GuzzleClient;
+use RuntimeException;
use Throwable;
/**
@@ -118,6 +120,8 @@ protected function computeAlgoliaTestPrefix(): void
/**
* Clean up all test indexes matching the test prefix.
+ *
+ * Every scheduled deletion is awaited before the first incomplete task is reported.
*/
protected function cleanupAlgoliaIndices(): void
{
@@ -138,8 +142,21 @@ protected function cleanupAlgoliaIndices(): void
}
}
+ $failure = null;
+
foreach ($tasks as $task) {
- $this->algolia->waitForTask($task['indexName'], $task['taskID']);
+ /** @var null|array|GetTaskResponse $result */
+ $result = $this->algolia->waitForTask($task['indexName'], $task['taskID']);
+
+ if (($result['status'] ?? null) !== 'published') {
+ $failure ??= new RuntimeException(
+ "Algolia index deletion task [{$task['taskID']}] for [{$task['indexName']}] did not complete."
+ );
+ }
+ }
+
+ if ($failure !== null) {
+ throw $failure;
}
}
}
diff --git a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php
index fd8a3a47c..c70418352 100644
--- a/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php
+++ b/src/foundation/src/Testing/Concerns/InteractsWithMeilisearch.php
@@ -144,6 +144,11 @@ protected function waitForMeilisearchTasks(int $timeoutMs = 5000): void
$tasks = $this->meilisearch->getTasks();
foreach ($tasks->getResults() as $task) {
+ if (! is_string($task['indexUid'] ?? null)
+ || ! str_starts_with($task['indexUid'], $this->meilisearchTestPrefix)) {
+ continue;
+ }
+
if (in_array($task['status'], ['enqueued', 'processing'], true)) {
/** @var int $taskUid */
$taskUid = $task['uid'];
diff --git a/src/scout/README.md b/src/scout/README.md
index 754767936..cd508acbb 100644
--- a/src/scout/README.md
+++ b/src/scout/README.md
@@ -11,5 +11,7 @@ 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.
+- Meilisearch requests use bounded retries and sign tenant tokens from an explicit parent-key UID and secret.
- Destructive index deletion requires the configured Scout prefix.
+- Boot-time lifecycle callbacks can prepare builders, documents, settings, and model flushes; external engines also support completion-aware filtered deletion.
+- `Searchable::removeAllFromSearch()` accepts an optional force flag, which the explicit `scout:flush` command enables.
diff --git a/src/scout/src/Builder.php b/src/scout/src/Builder.php
index 64376282c..e7b546238 100644
--- a/src/scout/src/Builder.php
+++ b/src/scout/src/Builder.php
@@ -132,7 +132,7 @@ public function __construct(
}
/**
- * Specify a custom index to perform this search on.
+ * Specify a custom index for search or filtered deletion.
*
* @return $this
*/
@@ -318,7 +318,7 @@ public function query(callable $callback): static
*/
public function raw(): mixed
{
- return $this->engine()->search($this);
+ return $this->preparedEngine()->search($this);
}
/**
@@ -339,7 +339,7 @@ public function withRawResults(callable $callback): static
*/
public function keys(): Collection
{
- return $this->engine()->keys($this);
+ return $this->preparedEngine()->keys($this);
}
/**
@@ -359,7 +359,7 @@ public function first(): ?Model
*/
public function get(): EloquentCollection
{
- return $this->engine()->get($this);
+ return $this->preparedEngine()->get($this);
}
/**
@@ -369,7 +369,7 @@ public function get(): EloquentCollection
*/
public function cursor(): LazyCollection
{
- return $this->engine()->cursor($this);
+ return $this->preparedEngine()->cursor($this);
}
/**
@@ -380,7 +380,7 @@ public function simplePaginate(
string $pageName = 'page',
?int $page = null
): PaginatorContract {
- $engine = $this->engine();
+ $engine = $this->preparedEngine();
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$perPage = $perPage ?: $this->model->getPerPage();
@@ -423,7 +423,7 @@ public function paginate(
string $pageName = 'page',
?int $page = null
): LengthAwarePaginatorContract {
- $engine = $this->engine();
+ $engine = $this->preparedEngine();
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$perPage = $perPage ?: $this->model->getPerPage();
@@ -465,7 +465,7 @@ public function paginateRaw(
string $pageName = 'page',
?int $page = null
): LengthAwarePaginatorContract {
- $engine = $this->engine();
+ $engine = $this->preparedEngine();
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$perPage = $perPage ?: $this->model->getPerPage();
@@ -502,7 +502,7 @@ public function simplePaginateRaw(
string $pageName = 'page',
?int $page = null
): PaginatorContract {
- $engine = $this->engine();
+ $engine = $this->preparedEngine();
$page = $page ?: Paginator::resolveCurrentPage($pageName);
$perPage = $perPage ?: $this->model->getPerPage();
@@ -574,6 +574,18 @@ public function applyAfterRawSearchCallback(mixed $results): mixed
return $results;
}
+ /**
+ * Get the prepared engine that should handle the query.
+ */
+ protected function preparedEngine(): Engine
+ {
+ $engine = $this->engine();
+
+ Scout::prepareBuilder($this, $engine);
+
+ return $engine;
+ }
+
/**
* Get the engine that should handle the query.
*/
diff --git a/src/scout/src/Console/FlushCommand.php b/src/scout/src/Console/FlushCommand.php
index 9082721f2..421724fcf 100644
--- a/src/scout/src/Console/FlushCommand.php
+++ b/src/scout/src/Console/FlushCommand.php
@@ -37,7 +37,7 @@ public function handle(): void
{
$class = $this->resolveModelClass((string) $this->argument('model'));
- $class::removeAllFromSearch();
+ $class::removeAllFromSearch(force: true);
$this->info("All [{$class}] records have been flushed.");
}
diff --git a/src/scout/src/Console/IndexCommand.php b/src/scout/src/Console/IndexCommand.php
index 4d4c750f3..5c28276f1 100644
--- a/src/scout/src/Console/IndexCommand.php
+++ b/src/scout/src/Console/IndexCommand.php
@@ -6,11 +6,13 @@
use Hypervel\Config\Repository;
use Hypervel\Console\Command;
+use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Scout\Contracts\UpdatesIndexSettings;
use Hypervel\Scout\EngineManager;
use Hypervel\Scout\Engines\Engine;
use Hypervel\Scout\Exceptions\NotSupportedException;
+use Hypervel\Scout\Scout;
use Hypervel\Support\Str;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -47,6 +49,7 @@ public function handle(EngineManager $manager, Repository $config): int
$options = ['primaryKey' => $key];
}
+ /** @var null|Model $model */
$model = null;
$modelName = (string) $this->argument('name');
@@ -71,6 +74,8 @@ public function handle(EngineManager $manager, Repository $config): int
$settings = $engine->configureSoftDeleteFilter($settings);
}
+ $settings = Scout::prepareIndexSettings($settings, $model, $engine, $name);
+
if ($settings) {
$engine->updateIndexSettings($name, $settings);
}
diff --git a/src/scout/src/Console/SyncIndexSettingsCommand.php b/src/scout/src/Console/SyncIndexSettingsCommand.php
index 29a871978..f07a4b915 100644
--- a/src/scout/src/Console/SyncIndexSettingsCommand.php
+++ b/src/scout/src/Console/SyncIndexSettingsCommand.php
@@ -6,9 +6,11 @@
use Hypervel\Config\Repository;
use Hypervel\Console\Command;
+use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Scout\Contracts\UpdatesIndexSettings;
use Hypervel\Scout\EngineManager;
+use Hypervel\Scout\Scout;
use Hypervel\Support\Str;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -59,6 +61,7 @@ public function handle(EngineManager $manager, Repository $config): int
$settings = [];
}
+ /** @var null|Model $model */
$model = null;
if (class_exists($name)) {
$model = new $name;
@@ -71,6 +74,7 @@ public function handle(EngineManager $manager, Repository $config): int
}
$indexName = $this->indexName($name, $config);
+ $settings = Scout::prepareIndexSettings($settings, $model, $engine, $indexName);
$engine->updateIndexSettings($indexName, $settings);
$this->info("Settings for the [{$indexName}] index synced successfully.");
diff --git a/src/scout/src/Contracts/DeletesByFilter.php b/src/scout/src/Contracts/DeletesByFilter.php
new file mode 100644
index 000000000..653fb46eb
--- /dev/null
+++ b/src/scout/src/Contracts/DeletesByFilter.php
@@ -0,0 +1,23 @@
+scoutMetadata(),
['objectID' => $model->getScoutKey()],
);
+
+ return Scout::prepareSearchableDocument($document, $model, $this);
})
->filter()
->values()
@@ -116,7 +124,6 @@ public function delete(EloquentCollection $models): void
public function search(Builder $builder): mixed
{
return $this->performSearch($builder, array_filter([
- 'filters' => $this->filters($builder),
'hitsPerPage' => $builder->limit,
]));
}
@@ -127,7 +134,6 @@ public function search(Builder $builder): mixed
public function paginate(Builder $builder, int $perPage, int $page): mixed
{
return $this->performSearch($builder, [
- 'filters' => $this->filters($builder),
'hitsPerPage' => $perPage,
'page' => $page - 1,
]);
@@ -139,6 +145,13 @@ public function paginate(Builder $builder, int $perPage, int $page): mixed
protected function performSearch(Builder $builder, array $options = []): mixed
{
$options = array_merge($builder->options, $options);
+ $filters = $this->combineFilters($options['filters'] ?? '', $this->filters($builder));
+
+ if ($filters === '') {
+ unset($options['filters']);
+ } else {
+ $options['filters'] = $filters;
+ }
if ($builder->callback !== null) {
return call_user_func(
@@ -287,6 +300,22 @@ protected function filters(Builder $builder): string
return $wheres->merge($whereIns)->merge($whereNotIns)->filter()->implode(' AND ');
}
+ /**
+ * Combine application and Builder filters without changing their precedence.
+ */
+ protected function combineFilters(string $applicationFilters, string $builderFilters): string
+ {
+ if (trim($applicationFilters) === '') {
+ return $builderFilters;
+ }
+
+ if ($builderFilters === '') {
+ return $applicationFilters;
+ }
+
+ return "({$applicationFilters}) AND ({$builderFilters})";
+ }
+
/**
* Format the given value for use in an Algolia filter.
*/
@@ -405,6 +434,37 @@ public function flush(Model $model): void
$this->algolia->clearObjects($model->indexableAs());
}
+ /**
+ * Delete every document matching the prepared Builder filters.
+ */
+ public function deleteByFilter(Builder $builder): void
+ {
+ Scout::prepareBuilder($builder, $this);
+
+ $filters = $this->combineFilters($builder->options['filters'] ?? '', $this->filters($builder));
+
+ if (trim($filters) === '') {
+ throw new InvalidArgumentException('Algolia filter deletion requires a non-empty filter.');
+ }
+
+ $index = $builder->index ?? $builder->model->indexableAs();
+
+ try {
+ /** @var array{taskID: int}|UpdatedAtResponse $response */
+ $response = $this->algolia->deleteBy($index, ['filters' => $filters]);
+ } catch (NotFoundException) {
+ // The index is already absent.
+ return;
+ }
+
+ /** @var null|array|GetTaskResponse $task */
+ $task = $this->algolia->waitForTask($index, $response['taskID']);
+
+ if (($task['status'] ?? null) !== 'published') {
+ throw new ScoutException('Algolia filter deletion did not complete successfully.');
+ }
+ }
+
/**
* Create a search index.
*
diff --git a/src/scout/src/Engines/MeilisearchEngine.php b/src/scout/src/Engines/MeilisearchEngine.php
index 8adafeca5..ea861cd00 100644
--- a/src/scout/src/Engines/MeilisearchEngine.php
+++ b/src/scout/src/Engines/MeilisearchEngine.php
@@ -5,30 +5,43 @@
namespace Hypervel\Scout\Engines;
use BackedEnum;
-use DateTimeImmutable;
+use DateTimeInterface;
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\DeletesByFilter;
use Hypervel\Scout\Contracts\SearchableInterface;
use Hypervel\Scout\Contracts\UpdatesIndexSettings;
+use Hypervel\Scout\Exceptions\ScoutException;
use Hypervel\Scout\Jobs\RemoveableScoutCollection;
+use Hypervel\Scout\Scout;
use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
use Hypervel\Support\LazyCollection;
+use InvalidArgumentException;
use Meilisearch\Client as MeilisearchClient;
use Meilisearch\Contracts\IndexesQuery;
use Meilisearch\Exceptions\ApiException;
use Meilisearch\Search\SearchResult;
-use RuntimeException;
/**
* Meilisearch search engine implementation.
*
* Provides full-text search using Meilisearch as the backend.
*/
-class MeilisearchEngine extends Engine implements UpdatesIndexSettings
+class MeilisearchEngine extends Engine implements DeletesByFilter, UpdatesIndexSettings
{
+ /**
+ * The maximum time to wait for a filtered deletion task.
+ */
+ protected const FILTER_DELETE_TIMEOUT_IN_MS = 500_000;
+
+ /**
+ * The interval between filtered deletion task checks.
+ */
+ protected const FILTER_DELETE_INTERVAL_IN_MS = 5_000;
+
/**
* Create a new MeilisearchEngine instance.
*/
@@ -66,11 +79,13 @@ public function update(EloquentCollection $models): void
return null;
}
- return array_merge(
+ $document = array_merge(
$searchableData,
$model->scoutMetadata(),
[$model->getScoutKeyName() => $model->getScoutKey()],
);
+
+ return Scout::prepareSearchableDocument($document, $model, $this);
})
->filter()
->values()
@@ -109,7 +124,6 @@ public function delete(EloquentCollection $models): void
public function search(Builder $builder): mixed
{
return $this->performSearch($builder, array_filter([
- 'filter' => $this->filters($builder),
'hitsPerPage' => $builder->limit,
'sort' => $this->buildSortFromOrderByClauses($builder),
]));
@@ -121,7 +135,6 @@ public function search(Builder $builder): mixed
public function paginate(Builder $builder, int $perPage, int $page): mixed
{
return $this->performSearch($builder, array_filter([
- 'filter' => $this->filters($builder),
'hitsPerPage' => $perPage,
'page' => $page,
'sort' => $this->buildSortFromOrderByClauses($builder),
@@ -136,6 +149,13 @@ protected function performSearch(Builder $builder, array $searchParams = []): mi
$meilisearch = $this->meilisearch->index($builder->index ?? $builder->model->searchableAs());
$searchParams = array_merge($builder->options, $searchParams);
+ $filter = $this->combineFilters($searchParams['filter'] ?? null, $this->filters($builder));
+
+ if ($filter === '') {
+ unset($searchParams['filter']);
+ } else {
+ $searchParams['filter'] = $filter;
+ }
if (array_key_exists('attributesToRetrieve', $searchParams)) {
$searchParams['attributesToRetrieve'] = array_merge(
@@ -217,6 +237,30 @@ protected function filters(Builder $builder): string
return $filters->values()->implode(' AND ');
}
+ /**
+ * Combine application and Builder filters without changing their precedence.
+ *
+ * @param null|array|string $applicationFilters
+ *
+ * @return array|string
+ */
+ protected function combineFilters(array|string|null $applicationFilters, string $builderFilters): string|array
+ {
+ if ($applicationFilters === null
+ || $applicationFilters === []
+ || (is_string($applicationFilters) && trim($applicationFilters) === '')) {
+ return $builderFilters;
+ }
+
+ if ($builderFilters === '') {
+ return $applicationFilters;
+ }
+
+ return is_array($applicationFilters)
+ ? [...$applicationFilters, $builderFilters]
+ : "({$applicationFilters}) AND ({$builderFilters})";
+ }
+
/**
* Get the sort array for the query.
*
@@ -366,6 +410,40 @@ public function flush(Model $model): void
$index->deleteAllDocuments();
}
+ /**
+ * Delete every document matching the prepared Builder filters.
+ */
+ public function deleteByFilter(Builder $builder): void
+ {
+ Scout::prepareBuilder($builder, $this);
+
+ $filter = $this->combineFilters($builder->options['filter'] ?? null, $this->filters($builder));
+
+ if ($filter === '') {
+ throw new InvalidArgumentException('Meilisearch filter deletion requires a non-empty filter.');
+ }
+
+ $index = $this->meilisearch->index($builder->index ?? $builder->model->indexableAs());
+ $task = $index->deleteDocuments(['filter' => $filter]);
+
+ // Bulk purges favor bounded service load over the SDK's interactive polling cadence.
+ $result = $this->meilisearch->waitForTask(
+ $task['taskUid'],
+ self::FILTER_DELETE_TIMEOUT_IN_MS,
+ self::FILTER_DELETE_INTERVAL_IN_MS,
+ );
+
+ if (($result['status'] ?? null) === 'failed'
+ && ($result['error']['code'] ?? null) === 'index_not_found') {
+ // Meilisearch reports an already-absent index through its asynchronous task.
+ return;
+ }
+
+ if (($result['status'] ?? null) !== 'succeeded') {
+ throw new ScoutException('Meilisearch filter deletion did not complete successfully.');
+ }
+ }
+
/**
* Create a search index.
*
@@ -476,46 +554,35 @@ public function deleteAllIndexes(?string $prefix = null): array
* without exposing the admin API key. All tenants share a single index,
* with data isolation enforced at query time via embedded filters.
*
- * @param array $searchRules Rules per index
+ * @param array|string}> $searchRules Rules per index
*
* @see https://www.meilisearch.com/blog/multi-tenancy-guide
*/
public function generateTenantToken(
array $searchRules,
- ?string $apiKeyUid = null,
- ?DateTimeImmutable $expiresAt = null
+ string $apiKeyUid,
+ string $apiKey,
+ ?DateTimeInterface $expiresAt = null
): string {
+ if ($apiKeyUid === '') {
+ throw new InvalidArgumentException('Meilisearch tenant tokens require a non-empty API key UID.');
+ }
+
+ // The SDK substitutes its client key for an empty option, which could sign for the wrong parent key.
+ if ($apiKey === '') {
+ throw new InvalidArgumentException('Meilisearch tenant tokens require a non-empty API key.');
+ }
+
return $this->meilisearch->generateTenantToken(
- $apiKeyUid ?? $this->getDefaultApiKeyUid(),
+ $apiKeyUid,
$searchRules,
[
+ 'apiKey' => $apiKey,
'expiresAt' => $expiresAt,
]
);
}
- /**
- * Get the default API key UID for tenant token generation.
- */
- protected function getDefaultApiKeyUid(): string
- {
- // The API key's UID is typically the first 8 chars of the key
- // This should be configured or retrieved from Meilisearch
- $keys = $this->meilisearch->getKeys();
-
- /** @var array}> $results */
- $results = $keys->getResults();
-
- foreach ($results as $key) {
- $actions = $key['actions'] ?? [];
- if (in_array('search', $actions) || in_array('*', $actions)) {
- return $key['uid'] ?? '';
- }
- }
-
- throw new RuntimeException('No valid API key found for tenant token generation.');
- }
-
/**
* Determine if the given model uses soft deletes.
*/
diff --git a/src/scout/src/Engines/TypesenseEngine.php b/src/scout/src/Engines/TypesenseEngine.php
index c4cde81f3..0c5a0e9e5 100644
--- a/src/scout/src/Engines/TypesenseEngine.php
+++ b/src/scout/src/Engines/TypesenseEngine.php
@@ -10,9 +10,11 @@
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Scout\Builder;
+use Hypervel\Scout\Contracts\DeletesByFilter;
use Hypervel\Scout\Contracts\SearchableInterface;
use Hypervel\Scout\Exceptions\NotSupportedException;
use Hypervel\Scout\Jobs\RemoveableScoutCollection;
+use Hypervel\Scout\Scout;
use Hypervel\Support\Collection;
use Hypervel\Support\LazyCollection;
use InvalidArgumentException;
@@ -28,7 +30,7 @@
*
* Provides full-text search using Typesense as the backend.
*/
-class TypesenseEngine extends Engine
+class TypesenseEngine extends Engine implements DeletesByFilter
{
/**
* The maximum number of results that can be fetched per page.
@@ -71,10 +73,12 @@ public function update(EloquentCollection $models): void
return null;
}
- return array_merge(
+ $document = array_merge(
$searchableData,
$model->scoutMetadata(),
);
+
+ return Scout::prepareSearchableDocument($document, $model, $this);
})
->filter()
->values()
@@ -308,7 +312,7 @@ public function buildSearchParameters(Builder $builder, int $page, ?int $perPage
$parameters = [
'q' => $builder->query,
'query_by' => $modelSettings['query_by'] ?? '',
- 'filter_by' => $this->filters($builder),
+ 'filter_by' => '',
'per_page' => $perPage,
'page' => $page,
'highlight_start_tag' => '',
@@ -331,6 +335,11 @@ public function buildSearchParameters(Builder $builder, int $page, ?int $perPage
$parameters = array_merge($parameters, $builder->options);
}
+ $parameters['filter_by'] = $this->combineFilters(
+ $parameters['filter_by'] ?? '',
+ $this->filters($builder),
+ );
+
if (! empty($builder->orders)) {
if (! empty($parameters['sort_by'])) {
$parameters['sort_by'] .= ',';
@@ -344,6 +353,22 @@ public function buildSearchParameters(Builder $builder, int $page, ?int $perPage
return $parameters;
}
+ /**
+ * Combine application and Builder filters without changing their precedence.
+ */
+ protected function combineFilters(string $applicationFilters, string $builderFilters): string
+ {
+ if (trim($applicationFilters) === '') {
+ return $builderFilters;
+ }
+
+ if ($builderFilters === '') {
+ return $applicationFilters;
+ }
+
+ return "({$applicationFilters}) && ({$builderFilters})";
+ }
+
/**
* Prepare the filters for a given search query.
*/
@@ -565,6 +590,30 @@ public function flush(Model $model): void
}
}
+ /**
+ * Delete every document matching the prepared Builder filters.
+ */
+ public function deleteByFilter(Builder $builder): void
+ {
+ Scout::prepareBuilder($builder, $this);
+
+ // Reuse search parameter assembly so model-level filters cannot be bypassed during deletion.
+ /** @var string $filters */
+ $filters = $this->buildSearchParameters($builder, 1, null)['filter_by'];
+
+ if (trim($filters) === '') {
+ throw new InvalidArgumentException('Typesense filter deletion requires a non-empty filter.');
+ }
+
+ $collection = $this->collection($builder->index ?? $builder->model->indexableAs());
+
+ try {
+ $collection->getDocuments()->delete(['filter_by' => $filters]);
+ } catch (ObjectNotFound) {
+ // The collection is already absent.
+ }
+ }
+
/**
* Create a search index.
*
@@ -602,6 +651,9 @@ protected function createCollectionFromModel(Model $model, string $collectionNam
$schema = $model->typesenseCollectionSchema();
}
+ // Keep target selection authoritative even when a settings callback supplies a different name.
+ unset($schema['name']);
+ $schema = Scout::prepareIndexSettings($schema, $model, $this, $collectionName);
$schema['name'] = $collectionName;
try {
diff --git a/src/scout/src/Scout.php b/src/scout/src/Scout.php
index 225b441a4..5445303e1 100644
--- a/src/scout/src/Scout.php
+++ b/src/scout/src/Scout.php
@@ -4,6 +4,7 @@
namespace Hypervel\Scout;
+use Closure;
use Hypervel\Context\CoroutineContext;
use Hypervel\Database\Eloquent\Collection as EloquentCollection;
use Hypervel\Database\Eloquent\Model;
@@ -13,31 +14,28 @@
use Hypervel\Scout\Jobs\RemoveFromSearch;
/**
- * Scout utility class for job customization and engine access.
+ * Scout utility class for lifecycle customization and engine access.
*
* Provides static configuration for customizing the job classes used
* when indexing models via the queue. Set these in a service provider
* boot method to use custom job implementations.
*
* Note: These static properties are set at boot time and read during
- * request handling. This is safe in Swoole/coroutine environments
- * because they store class names (strings), not stateful objects.
+ * request handling. Job classes are stable strings, while lifecycle
+ * callbacks may capture objects and therefore must never be registered
+ * from request handling.
*/
class Scout
{
/**
- * The job class that makes models searchable.
- *
- * @var class-string
+ * The default job class that makes models searchable.
*/
- public static string $makeSearchableJob = MakeSearchable::class;
+ protected const DEFAULT_MAKE_SEARCHABLE_JOB = MakeSearchable::class;
/**
- * The job class that removes models from the search index.
- *
- * @var class-string
+ * The default job class that removes models from the search index.
*/
- public static string $removeFromSearchJob = RemoveFromSearch::class;
+ protected const DEFAULT_REMOVE_FROM_SEARCH_JOB = RemoveFromSearch::class;
/**
* Coroutine-local context key indicating that scout:import is currently running.
@@ -52,6 +50,48 @@ class Scout
*/
public const IMPORT_PROGRESS_CONTEXT_KEY = '__scout.import_progress';
+ /**
+ * The job class that makes models searchable.
+ *
+ * @var class-string
+ */
+ public static string $makeSearchableJob = self::DEFAULT_MAKE_SEARCHABLE_JOB;
+
+ /**
+ * The job class that removes models from the search index.
+ *
+ * @var class-string
+ */
+ public static string $removeFromSearchJob = self::DEFAULT_REMOVE_FROM_SEARCH_JOB;
+
+ /**
+ * The callback that prepares a Builder before terminal execution.
+ *
+ * @var null|(Closure(Builder, Engine): void)
+ */
+ protected static ?Closure $prepareBuilderCallback = null;
+
+ /**
+ * The callback that prepares a final searchable document.
+ *
+ * @var null|(Closure(array, Model, Engine): array)
+ */
+ protected static ?Closure $prepareSearchableDocumentCallback = null;
+
+ /**
+ * The callback that prepares final index settings.
+ *
+ * @var null|(Closure(array, null|Model, Engine, string): array)
+ */
+ protected static ?Closure $prepareIndexSettingsCallback = null;
+
+ /**
+ * The callback that guards a whole-model index flush.
+ *
+ * @var null|(Closure(Model, Engine, bool): void)
+ */
+ protected static ?Closure $guardModelFlushCallback = null;
+
/**
* Get a Scout engine instance by name.
*/
@@ -60,6 +100,106 @@ public static function engine(?string $name = null): Engine
return app(EngineManager::class)->engine($name);
}
+ /**
+ * Specify the callback that prepares a Builder before terminal execution.
+ *
+ * Boot-only. The callback persists for the worker lifetime, so registering
+ * it per request would leak captured request state into later requests.
+ *
+ * @param callable(Builder, Engine): void $callback
+ */
+ public static function prepareBuilderUsing(callable $callback): void
+ {
+ static::$prepareBuilderCallback = Closure::fromCallable($callback);
+ }
+
+ /**
+ * Prepare a Builder before terminal execution.
+ */
+ public static function prepareBuilder(Builder $builder, Engine $engine): void
+ {
+ if (static::$prepareBuilderCallback !== null) {
+ (static::$prepareBuilderCallback)($builder, $engine);
+ }
+ }
+
+ /**
+ * Specify the callback that prepares a final searchable document.
+ *
+ * Boot-only. The callback persists for the worker lifetime, so registering
+ * it per request would leak captured request state into later requests.
+ *
+ * @param callable(array, Model, Engine): array $callback
+ */
+ public static function prepareSearchableDocumentUsing(callable $callback): void
+ {
+ static::$prepareSearchableDocumentCallback = Closure::fromCallable($callback);
+ }
+
+ /**
+ * Prepare a final searchable document.
+ *
+ * @param array $document
+ *
+ * @return array
+ */
+ public static function prepareSearchableDocument(array $document, Model $model, Engine $engine): array
+ {
+ return static::$prepareSearchableDocumentCallback === null
+ ? $document
+ : (static::$prepareSearchableDocumentCallback)($document, $model, $engine);
+ }
+
+ /**
+ * Specify the callback that prepares final index settings.
+ *
+ * Boot-only. The callback persists for the worker lifetime, so registering
+ * it per request would leak captured request state into later requests.
+ *
+ * @param callable(array, null|Model, Engine, string): array $callback
+ */
+ public static function prepareIndexSettingsUsing(callable $callback): void
+ {
+ static::$prepareIndexSettingsCallback = Closure::fromCallable($callback);
+ }
+
+ /**
+ * Prepare final index settings.
+ *
+ * @param array $settings
+ *
+ * @return array
+ */
+ public static function prepareIndexSettings(array $settings, ?Model $model, Engine $engine, string $index): array
+ {
+ return static::$prepareIndexSettingsCallback === null
+ ? $settings
+ : (static::$prepareIndexSettingsCallback)($settings, $model, $engine, $index);
+ }
+
+ /**
+ * Specify the callback that guards a whole-model index flush.
+ *
+ * Boot-only. The callback persists for the worker lifetime, so registering
+ * it per request would leak captured request state into later requests.
+ *
+ * @param callable(Model, Engine, bool): void $callback
+ */
+ public static function guardModelFlushUsing(callable $callback): void
+ {
+ static::$guardModelFlushCallback = Closure::fromCallable($callback);
+ }
+
+ /**
+ * Guard a whole-model index flush.
+ */
+ public static function guardModelFlush(Model $model, Engine $engine, bool $force): void
+ {
+ if (static::$guardModelFlushCallback !== null) {
+ (static::$guardModelFlushCallback)($model, $engine, $force);
+ }
+ }
+
/**
* Specify the job class that should make models searchable.
*
@@ -161,7 +301,11 @@ public static function reportImportProgress(EloquentCollection $models): void
*/
public static function flushState(): void
{
- static::$makeSearchableJob = MakeSearchable::class;
- static::$removeFromSearchJob = RemoveFromSearch::class;
+ static::$makeSearchableJob = self::DEFAULT_MAKE_SEARCHABLE_JOB;
+ static::$removeFromSearchJob = self::DEFAULT_REMOVE_FROM_SEARCH_JOB;
+ static::$prepareBuilderCallback = null;
+ static::$prepareSearchableDocumentCallback = null;
+ static::$prepareIndexSettingsCallback = null;
+ static::$guardModelFlushCallback = null;
}
}
diff --git a/src/scout/src/Searchable.php b/src/scout/src/Searchable.php
index 471116c85..c8a9e1e2d 100644
--- a/src/scout/src/Searchable.php
+++ b/src/scout/src/Searchable.php
@@ -306,10 +306,14 @@ public function searchableSync(): void
/**
* Remove all instances of the model from the search index.
*/
- public static function removeAllFromSearch(): void
+ public static function removeAllFromSearch(bool $force = false): void
{
$self = new static;
- $self->searchableUsing()->flush($self);
+ $engine = $self->searchableUsing();
+
+ Scout::guardModelFlush($self, $engine, $force);
+
+ $engine->flush($self);
}
/**
diff --git a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php
index e83bfee72..b356abc21 100644
--- a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php
+++ b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php
@@ -21,6 +21,7 @@
use Meilisearch\Endpoints\Indexes;
use Meilisearch\Exceptions\TimeOutException;
use Mockery as m;
+use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Throwable;
@@ -164,7 +165,7 @@ public function testMeilisearchWaitAcceptsSuccessfulTasks(): void
$client->shouldReceive('getTasks')
->once()
->andReturn(new TasksResults([
- 'results' => [['uid' => 17, 'status' => 'enqueued']],
+ 'results' => [['uid' => 17, 'indexUid' => 'test_users', 'status' => 'enqueued']],
]));
$client->shouldReceive('waitForTask')
->once()
@@ -172,6 +173,7 @@ public function testMeilisearchWaitAcceptsSuccessfulTasks(): void
->andReturn(['uid' => 17, 'status' => 'succeeded']);
$harness = new MeilisearchOptInHarness;
+ $harness->usePrefix('test_');
$harness->useClient($client);
$harness->waitForTasks();
}
@@ -182,7 +184,7 @@ public function testMeilisearchWaitRejectsFailedTasks(): void
$client->shouldReceive('getTasks')
->once()
->andReturn(new TasksResults([
- 'results' => [['uid' => 17, 'status' => 'processing']],
+ 'results' => [['uid' => 17, 'indexUid' => 'test_users', 'status' => 'processing']],
]));
$client->shouldReceive('waitForTask')
->once()
@@ -194,6 +196,7 @@ public function testMeilisearchWaitRejectsFailedTasks(): void
]);
$harness = new MeilisearchOptInHarness;
+ $harness->usePrefix('test_');
$harness->useClient($client);
$this->expectException(RuntimeException::class);
@@ -209,7 +212,7 @@ public function testMeilisearchWaitPropagatesTimeouts(): void
$client->shouldReceive('getTasks')
->once()
->andReturn(new TasksResults([
- 'results' => [['uid' => 17, 'status' => 'enqueued']],
+ 'results' => [['uid' => 17, 'indexUid' => 'test_users', 'status' => 'enqueued']],
]));
$client->shouldReceive('waitForTask')
->once()
@@ -217,6 +220,7 @@ public function testMeilisearchWaitPropagatesTimeouts(): void
->andThrow($timeout);
$harness = new MeilisearchOptInHarness;
+ $harness->usePrefix('test_');
$harness->useClient($client);
$this->expectExceptionObject($timeout);
@@ -224,6 +228,24 @@ public function testMeilisearchWaitPropagatesTimeouts(): void
$harness->waitForTasks();
}
+ public function testMeilisearchWaitIgnoresPendingTasksForOtherPrefixes(): void
+ {
+ $client = m::mock(MeilisearchClient::class);
+ $client->shouldReceive('getTasks')
+ ->once()
+ ->andReturn(new TasksResults([
+ 'results' => [['uid' => 17, 'indexUid' => 'other_users', 'status' => 'enqueued']],
+ ]));
+ $client->shouldNotReceive('waitForTask');
+
+ $harness = new MeilisearchOptInHarness;
+ $harness->usePrefix('test_');
+ $harness->useClient($client);
+ $harness->waitForTasks();
+
+ $this->assertTrue(true);
+ }
+
public function testMeilisearchCleanupWaitsForExactDeletionTasks(): void
{
$index = m::mock(Indexes::class);
@@ -455,21 +477,105 @@ public function testAlgoliaCleanupWaitsForExactDeletionTasks(): void
$harness->runCleanup();
}
+ #[DataProvider('incompleteAlgoliaDeletionTasks')]
+ public function testAlgoliaCleanupRejectsIncompleteDeletionTasks(?array $task): void
+ {
+ $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)
+ ->andReturn($task);
+
+ $harness = new AlgoliaOptInHarness;
+ $harness->usePrefix('test_');
+ $harness->useClient($client);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Algolia index deletion task [31] for [test_users] did not complete.');
+
+ $harness->runCleanup();
+ }
+
+ /**
+ * Provide incomplete Algolia index-deletion tasks.
+ *
+ * @return array
+ */
+ public static function incompleteAlgoliaDeletionTasks(): array
+ {
+ return [
+ 'swallowed polling failure' => [null],
+ 'non-published task' => [['status' => 'processing']],
+ ];
+ }
+
+ public function testAlgoliaCleanupWaitsForRemainingTasksBeforeRejectingAnIncompleteResult(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldReceive('listIndices')
+ ->once()
+ ->andReturn(['items' => [
+ ['name' => 'test_users'],
+ ['name' => 'test_orders'],
+ ]]);
+ $client->shouldReceive('deleteIndex')
+ ->once()
+ ->with('test_users')
+ ->andReturn(['taskID' => 31]);
+ $client->shouldReceive('deleteIndex')
+ ->once()
+ ->with('test_orders')
+ ->andReturn(['taskID' => 32]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with('test_users', 31)
+ ->andReturn(null);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with('test_orders', 32)
+ ->andReturn(['status' => 'published']);
+
+ $harness = new AlgoliaOptInHarness;
+ $harness->usePrefix('test_');
+ $harness->useClient($client);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Algolia index deletion task [31] for [test_users] did not complete.');
+
+ $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']]]);
+ ->andReturn(['items' => [
+ ['name' => 'test_users'],
+ ['name' => 'test_orders'],
+ ]]);
$client->shouldReceive('deleteIndex')
->once()
->with('test_users')
->andReturn(['taskID' => 31]);
+ $client->shouldReceive('deleteIndex')
+ ->once()
+ ->with('test_orders')
+ ->andReturn(['taskID' => 32]);
$client->shouldReceive('waitForTask')
->once()
->with('test_users', 31)
->andThrow($failure);
+ $client->shouldNotReceive('waitForTask')
+ ->with('test_orders', 32);
$harness = new AlgoliaOptInHarness;
$harness->usePrefix('test_');
diff --git a/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php b/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php
index 373f6962e..bf9f05c46 100644
--- a/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php
+++ b/tests/Integration/Scout/Algolia/AlgoliaFilteringIntegrationTest.php
@@ -141,6 +141,34 @@ public function testBackedEnumsRetainTheirNativeFilterValues(): void
);
}
+ public function testApplicationFiltersComposeWithBuilderFiltersForSearchAndDeletion(): void
+ {
+ $models = SearchableModel::withoutSyncingToSearch(fn () => new EloquentCollection([
+ SearchableModel::create(['id' => 701, 'title' => 'Target', 'body' => 'Body']),
+ SearchableModel::create(['id' => 702, 'title' => 'Other', 'body' => 'Body']),
+ SearchableModel::create(['id' => 703, 'title' => 'Excluded', 'body' => 'Body']),
+ ]));
+ $index = $models->first()->searchableAs();
+
+ $this->engine->update($models);
+ $this->pollSearch($index, '', 3);
+
+ $results = SearchableModel::search('')
+ ->options(['filters' => "title:'Target' OR title:'Other'"])
+ ->where('id', 701)
+ ->get();
+
+ $this->assertSame([701], $results->pluck('id')->all());
+
+ $this->engine->deleteByFilter(
+ SearchableModel::search('')
+ ->options(['filters' => "title:'Target' OR title:'Other'"])
+ ->where('id', 701)
+ );
+
+ $this->assertCount(2, $this->pollSearch($index, '', 2));
+ }
+
/**
* Poll an Algolia index until the search returns the expected hit count.
*
diff --git a/tests/Integration/Scout/Meilisearch/MeilisearchCommandsIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchCommandsIntegrationTest.php
index 99af74707..f6916f47c 100644
--- a/tests/Integration/Scout/Meilisearch/MeilisearchCommandsIntegrationTest.php
+++ b/tests/Integration/Scout/Meilisearch/MeilisearchCommandsIntegrationTest.php
@@ -107,7 +107,11 @@ public function testScopedDeleteAllIndexesPreservesUnrelatedIndexes(): void
$this->createTestIndex('scoped_b');
// One unprefixed index — create directly, clean up manually below.
- $this->meilisearch->createIndex($unrelatedIndex);
+ $task = $this->meilisearch->createIndex($unrelatedIndex);
+
+ /** @var int $taskUid */
+ $taskUid = $task['taskUid'];
+ $this->waitForMeilisearchTask($taskUid);
$this->waitForMeilisearchTasks();
diff --git a/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php
index e67dbb8c5..c6e6c266f 100644
--- a/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php
+++ b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php
@@ -8,6 +8,8 @@
use Hypervel\Scout\Jobs\RemoveFromSearch;
use Hypervel\Tests\Scout\Models\CustomScoutKeyModel;
use Hypervel\Tests\Scout\Models\SearchableModel;
+use Meilisearch\Client;
+use Meilisearch\Exceptions\ApiException;
/**
* Integration tests for MeilisearchEngine core operations.
@@ -43,6 +45,61 @@ public function testUpdateWithMultipleModels(): void
$this->assertCount(3, $results->getHits());
}
+ public function testTenantTokenUsesTheMatchingParentKeyIdentityAndSigner(): void
+ {
+ $models = SearchableModel::withoutSyncingToSearch(fn () => new EloquentCollection([
+ SearchableModel::create(['id' => 701, 'title' => 'Visible', 'body' => 'Body']),
+ SearchableModel::create(['id' => 702, 'title' => 'Hidden', 'body' => 'Body']),
+ ]));
+ $indexName = $models->first()->indexableAs();
+ $this->engine->update($models);
+ $settingsTask = $this->meilisearch->index($indexName)->updateFilterableAttributes(['id']);
+ $this->meilisearch->waitForTask($settingsTask['taskUid']);
+ $this->waitForMeilisearchTasks();
+
+ $key = $this->meilisearch->createKey([
+ 'name' => $this->testPrefix . 'tenant-token-parent',
+ 'description' => 'Scout tenant-token integration test',
+ 'actions' => ['search'],
+ 'indexes' => [$indexName],
+ 'expiresAt' => null,
+ ]);
+ $uid = $key->getUid();
+ $secret = $key->getKey();
+ $cleanupIdentifier = $uid ?? $secret;
+
+ try {
+ $this->assertNotNull($uid);
+ $this->assertNotNull($secret);
+
+ $rules = [$indexName => ['filter' => 'id = 701']];
+ $token = $this->engine->generateTenantToken($rules, $uid, $secret);
+ $tenantClient = new Client($this->getMeilisearchHost(), $token);
+
+ $this->assertSame(
+ [701],
+ collect($tenantClient->index($indexName)->search('')->getHits())->pluck('id')->all(),
+ );
+
+ $invalidToken = $this->engine->generateTenantToken(
+ $rules,
+ $uid,
+ 'deliberately-wrong-signing-key',
+ );
+
+ try {
+ (new Client($this->getMeilisearchHost(), $invalidToken))->index($indexName)->search('');
+ $this->fail('A token signed by a key that does not match its UID was accepted.');
+ } catch (ApiException $exception) {
+ $this->assertSame(403, $exception->httpStatus);
+ }
+ } finally {
+ if ($cleanupIdentifier !== null) {
+ $this->meilisearch->deleteKey($cleanupIdentifier);
+ }
+ }
+ }
+
public function testDeleteRemovesModelsFromMeilisearch(): void
{
$model = SearchableModel::create(['title' => 'To Delete', 'body' => 'Content']);
diff --git a/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php
index dcafd1f4b..e3f524148 100644
--- a/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php
+++ b/tests/Integration/Scout/Meilisearch/MeilisearchFilteringIntegrationTest.php
@@ -6,6 +6,7 @@
use Hypervel\Database\Eloquent\Collection as EloquentCollection;
use Hypervel\Tests\Scout\Models\SearchableModel;
+use Meilisearch\Exceptions\ApiException;
/**
* Integration tests for Meilisearch filtering operations.
@@ -177,6 +178,53 @@ public function testBackedEnumsAndEscapedSetValuesReachMeilisearch(): void
$this->assertSame([401, 402], $results->pluck('id')->sort()->values()->all());
}
+
+ public function testApplicationFiltersComposeWithBuilderFiltersForSearchAndDeletion(): void
+ {
+ SearchableModel::withoutSyncingToSearch(function (): void {
+ SearchableModel::create(['id' => 701, 'title' => 'Target', 'body' => 'Body']);
+ SearchableModel::create(['id' => 702, 'title' => 'Other', 'body' => 'Body']);
+ SearchableModel::create(['id' => 703, 'title' => 'Excluded', 'body' => 'Body']);
+ });
+ $this->engine->update(SearchableModel::query()->get());
+ $this->waitForMeilisearchTasks();
+
+ $results = SearchableModel::search('')
+ ->options(['filter' => [['title="Target"', 'title="Other"']]])
+ ->where('id', 701)
+ ->get();
+
+ $this->assertSame([701], $results->pluck('id')->all());
+
+ $this->engine->deleteByFilter(
+ SearchableModel::search('')
+ ->options(['filter' => [['title="Target"', 'title="Other"']]])
+ ->where('id', 701)
+ );
+
+ $this->assertSame(
+ [702, 703],
+ SearchableModel::search('')->get()->pluck('id')->sort()->values()->all(),
+ );
+ }
+
+ public function testFilteredDeletionTreatsAMissingIndexAsAlreadyDeleted(): void
+ {
+ $indexName = $this->prefixedIndexName('missing_filter_delete');
+
+ $this->engine->deleteByFilter(
+ SearchableModel::search('')
+ ->within($indexName)
+ ->where('id', 701)
+ );
+
+ try {
+ $this->meilisearch->getIndex($indexName);
+ $this->fail('Filtered deletion created the missing index.');
+ } catch (ApiException $exception) {
+ $this->assertSame(404, $exception->httpStatus);
+ }
+ }
}
enum MeilisearchFilterId: int
diff --git a/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php
index bc3f581a3..e33b8525c 100644
--- a/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php
+++ b/tests/Integration/Scout/Typesense/TypesenseFilteringIntegrationTest.php
@@ -7,6 +7,7 @@
use Hypervel\Database\Eloquent\Collection as EloquentCollection;
use Hypervel\Tests\Scout\Models\TypesenseSearchableModel;
use InvalidArgumentException;
+use Typesense\Exceptions\ObjectNotFound;
/**
* Integration tests for Typesense filtering operations.
@@ -148,6 +149,49 @@ public function testBackedEnumsRetainTheirNativeFilterValues(): void
);
}
+ public function testApplicationFiltersComposeWithBuilderFiltersForSearchAndDeletion(): void
+ {
+ TypesenseSearchableModel::withoutSyncingToSearch(function (): void {
+ TypesenseSearchableModel::create(['id' => 701, 'title' => 'Target', 'body' => 'Body']);
+ TypesenseSearchableModel::create(['id' => 702, 'title' => 'Other', 'body' => 'Body']);
+ TypesenseSearchableModel::create(['id' => 703, 'title' => 'Excluded', 'body' => 'Body']);
+ });
+ $this->engine->update(TypesenseSearchableModel::query()->get());
+
+ $results = TypesenseSearchableModel::search('')
+ ->options(['filter_by' => 'title:=Target || title:=Other'])
+ ->where('ranking', 701)
+ ->get();
+
+ $this->assertSame([701], $results->pluck('id')->all());
+
+ $this->engine->deleteByFilter(
+ TypesenseSearchableModel::search('')
+ ->options(['filter_by' => 'title:=Target || title:=Other'])
+ ->where('ranking', 701)
+ );
+
+ $this->assertSame(
+ [702, 703],
+ TypesenseSearchableModel::search('')->get()->pluck('id')->sort()->values()->all(),
+ );
+ }
+
+ public function testFilteredDeletionTreatsAMissingCollectionAsAlreadyDeleted(): void
+ {
+ $collectionName = $this->prefixedCollectionName('missing_filter_delete');
+
+ $this->engine->deleteByFilter(
+ TypesenseSearchableModel::search('')
+ ->within($collectionName)
+ ->where('ranking', 701)
+ );
+
+ $this->expectException(ObjectNotFound::class);
+
+ $this->typesense->getCollections()[$collectionName]->retrieve();
+ }
+
public function testUnsupportedComparisonOperatorIsRejected(): void
{
$this->expectException(InvalidArgumentException::class);
diff --git a/tests/Scout/Feature/SearchableModelTest.php b/tests/Scout/Feature/SearchableModelTest.php
index 8b3d8fb28..fd110ee4e 100644
--- a/tests/Scout/Feature/SearchableModelTest.php
+++ b/tests/Scout/Feature/SearchableModelTest.php
@@ -6,11 +6,15 @@
use Hypervel\Database\Eloquent\Model;
use Hypervel\Scout\Builder;
+use Hypervel\Scout\EngineManager;
+use Hypervel\Scout\Engines\Engine;
+use Hypervel\Scout\Scout;
use Hypervel\Support\Collection as BaseCollection;
use Hypervel\Tests\Scout\Models\SearchableModel;
use Hypervel\Tests\Scout\Models\SoftDeletableSearchableModel;
use Hypervel\Tests\Scout\ScoutTestCase;
use LogicException;
+use Mockery as m;
use RuntimeException;
class SearchableModelTest extends ScoutTestCase
@@ -215,6 +219,31 @@ public function testMakeAllSearchableQueryReturnsBuilder(): void
$this->assertInstanceOf(\Hypervel\Database\Eloquent\Builder::class, $query);
}
+ public function testRemoveAllFromSearchGuardsTheResolvedEngineBeforeFlushing(): void
+ {
+ $engine = m::mock(Engine::class);
+ $engine->shouldReceive('flush')
+ ->once()
+ ->with(m::type(SearchableModel::class));
+ $manager = m::mock(EngineManager::class);
+ $manager->shouldReceive('engine')->once()->andReturn($engine);
+ $this->app->instance(EngineManager::class, $manager);
+ $guarded = false;
+ Scout::guardModelFlushUsing(function (Model $model, Engine $givenEngine, bool $force) use (
+ $engine,
+ &$guarded
+ ): void {
+ $this->assertInstanceOf(SearchableModel::class, $model);
+ $this->assertSame($engine, $givenEngine);
+ $this->assertFalse($force);
+ $guarded = true;
+ });
+
+ SearchableModel::removeAllFromSearch();
+
+ $this->assertTrue($guarded);
+ }
+
public function testScoutMetadataCanBeSetAndRetrieved(): void
{
$model = new SearchableModel;
diff --git a/tests/Scout/Unit/BuilderTest.php b/tests/Scout/Unit/BuilderTest.php
index 9e234cc4e..3e6889fb0 100644
--- a/tests/Scout/Unit/BuilderTest.php
+++ b/tests/Scout/Unit/BuilderTest.php
@@ -13,7 +13,9 @@
use Hypervel\Scout\Contracts\PaginatesEloquentModels;
use Hypervel\Scout\Contracts\PaginatesEloquentModelsUsingDatabase;
use Hypervel\Scout\Engines\Engine;
+use Hypervel\Scout\Scout;
use Hypervel\Support\Collection;
+use Hypervel\Support\LazyCollection;
use Hypervel\Tests\TestCase;
use Mockery as m;
@@ -28,6 +30,20 @@ public function testBuilderStoresQueryAndModel(): void
$this->assertSame('test query', $builder->query);
}
+ public function testBuilderIsNotPreparedUntilTerminalExecution(): void
+ {
+ $model = m::mock(Model::class);
+ $calls = 0;
+
+ Scout::prepareBuilderUsing(function () use (&$calls): void {
+ ++$calls;
+ });
+
+ new Builder($model, 'test query');
+
+ $this->assertSame(0, $calls);
+ }
+
public function testWhereAddsConstraint(): void
{
$model = m::mock(Model::class);
@@ -353,6 +369,69 @@ public function testFirstReturnsNullWhenNoResults(): void
$this->assertNull($result);
}
+ public function testResultTerminalsPrepareBuilderExactlyOnce(): void
+ {
+ $model = m::mock(Model::class);
+ $engine = m::mock(Engine::class);
+ $model->shouldReceive('searchableUsing')->times(5)->andReturn($engine);
+ $engine->shouldReceive('search')->once()->andReturn([]);
+ $engine->shouldReceive('keys')->once()->andReturn(new Collection);
+ $engine->shouldReceive('get')->twice()->andReturn(new EloquentCollection);
+ $engine->shouldReceive('cursor')->once()->andReturn(new LazyCollection([]));
+ $builder = new Builder($model, 'query');
+ $prepared = [];
+
+ Scout::prepareBuilderUsing(function (Builder $givenBuilder, Engine $givenEngine) use (
+ $builder,
+ $engine,
+ &$prepared
+ ): void {
+ $this->assertSame($builder, $givenBuilder);
+ $this->assertSame($engine, $givenEngine);
+ $prepared[] = true;
+ });
+
+ $builder->raw();
+ $builder->keys();
+ $builder->get();
+ $builder->first();
+ $builder->cursor();
+
+ $this->assertCount(5, $prepared);
+ }
+
+ public function testPaginationTerminalsPrepareBeforeEngineExecution(): void
+ {
+ Paginator::currentPageResolver(fn () => 1);
+ Paginator::currentPathResolver(fn () => 'http://localhost/foo');
+
+ $model = m::mock(Model::class);
+ $model->shouldReceive('getPerPage')->times(4)->andReturn(15);
+ $engine = m::mock(Engine::class . ', ' . PaginatesEloquentModels::class);
+ $model->shouldReceive('searchableUsing')->times(4)->andReturn($engine);
+ $engine->shouldReceive('paginate')->twice()->andReturn(new LengthAwarePaginator([], 0, 15, 1));
+ $engine->shouldReceive('simplePaginate')->twice()->andReturn(new Paginator([], 15, 1));
+ $builder = new Builder($model, 'query');
+ $prepared = 0;
+
+ Scout::prepareBuilderUsing(function (Builder $givenBuilder, Engine $givenEngine) use (
+ $builder,
+ $engine,
+ &$prepared
+ ): void {
+ $this->assertSame($builder, $givenBuilder);
+ $this->assertSame($engine, $givenEngine);
+ ++$prepared;
+ });
+
+ $builder->simplePaginate();
+ $builder->paginate();
+ $builder->paginateRaw();
+ $builder->simplePaginateRaw();
+
+ $this->assertSame(4, $prepared);
+ }
+
public function testPaginationCorrectlyHandlesPaginatedResults(): void
{
Paginator::currentPageResolver(function () {
diff --git a/tests/Scout/Unit/Console/FlushCommandTest.php b/tests/Scout/Unit/Console/FlushCommandTest.php
new file mode 100644
index 000000000..f01a5dc66
--- /dev/null
+++ b/tests/Scout/Unit/Console/FlushCommandTest.php
@@ -0,0 +1,30 @@
+assertInstanceOf(SearchableModel::class, $model);
+ $this->assertTrue($force);
+ $guarded = true;
+ });
+
+ $this->artisan('scout:flush', ['model' => SearchableModel::class])
+ ->expectsOutputToContain('have been flushed')
+ ->assertSuccessful();
+
+ $this->assertTrue($guarded);
+ }
+}
diff --git a/tests/Scout/Unit/Console/ImportCommandTest.php b/tests/Scout/Unit/Console/ImportCommandTest.php
index 686948ae9..e9525dcc5 100644
--- a/tests/Scout/Unit/Console/ImportCommandTest.php
+++ b/tests/Scout/Unit/Console/ImportCommandTest.php
@@ -6,6 +6,7 @@
use Hypervel\Context\CoroutineContext;
use Hypervel\Database\Eloquent\Collection;
+use Hypervel\Database\Eloquent\Model;
use Hypervel\Foundation\Console\Kernel;
use Hypervel\Scout\EngineManager;
use Hypervel\Scout\Engines\Engine;
@@ -13,6 +14,7 @@
use Hypervel\Scout\Exceptions\ScoutException;
use Hypervel\Scout\Jobs\MakeSearchable;
use Hypervel\Scout\Jobs\RemoveFromSearch;
+use Hypervel\Scout\Scout;
use Hypervel\Support\Facades\Bus;
use Hypervel\Support\Facades\Event;
use Hypervel\Tests\Scout\Models\SearchableModel;
@@ -52,6 +54,12 @@ public function testScoutImportIgnoresQueueEnabledConfigAndRunsSync(): void
public function testScoutImportFreshIgnoresQueueEnabledConfig(): void
{
$this->app->make('config')->set('scout.queue.enabled', true);
+ $guarded = false;
+ Scout::guardModelFlushUsing(function (Model $model, Engine $engine, bool $force) use (&$guarded): void {
+ $this->assertInstanceOf(SearchableModel::class, $model);
+ $this->assertFalse($force);
+ $guarded = true;
+ });
for ($i = 1; $i <= 3; ++$i) {
SearchableModel::create(['title' => "Title {$i}", 'body' => 'Body']);
@@ -65,6 +73,7 @@ public function testScoutImportFreshIgnoresQueueEnabledConfig(): void
Bus::assertNotDispatched(MakeSearchable::class);
Bus::assertNotDispatched(RemoveFromSearch::class);
+ $this->assertTrue($guarded);
}
public function testScoutImportDoesNotForgetExistingModelsImportedListeners(): void
diff --git a/tests/Scout/Unit/Console/IndexCommandTest.php b/tests/Scout/Unit/Console/IndexCommandTest.php
index f74ff313b..2fbd3ab7a 100644
--- a/tests/Scout/Unit/Console/IndexCommandTest.php
+++ b/tests/Scout/Unit/Console/IndexCommandTest.php
@@ -5,11 +5,13 @@
namespace Hypervel\Tests\Scout\Unit\Console;
use Hypervel\Config\Repository;
+use Hypervel\Database\Eloquent\Model;
use Hypervel\Scout\Console\IndexCommand;
use Hypervel\Scout\Contracts\UpdatesIndexSettings;
use Hypervel\Scout\EngineManager;
use Hypervel\Scout\Engines\Engine;
use Hypervel\Scout\Exceptions\NotSupportedException;
+use Hypervel\Scout\Scout;
use Hypervel\Tests\TestCase;
use Mockery as m;
use RuntimeException;
@@ -87,6 +89,41 @@ public function testPhysicalIndexSettingsAreUsedAsFallback(): void
$this->assertSame(0, $command->handle($manager, $config));
}
+ public function testLifecycleCallbackCanPrepareAnEmptyNamedIndexEntry(): void
+ {
+ $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class);
+ $engine->shouldReceive('createIndex')->once()->with('prod_posts', []);
+ $engine->shouldReceive('updateIndexSettings')
+ ->once()
+ ->with('prod_posts', ['filterableAttributes' => ['tenant_id']]);
+ $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(null);
+
+ Scout::prepareIndexSettingsUsing(function (
+ array $settings,
+ ?Model $model,
+ Engine $givenEngine,
+ string $index
+ ) use ($engine): array {
+ $this->assertSame([], $settings);
+ $this->assertNull($model);
+ $this->assertSame($engine, $givenEngine);
+ $this->assertSame('prod_posts', $index);
+
+ return ['filterableAttributes' => ['tenant_id']];
+ });
+
+ $command = $this->command('posts');
+ $command->shouldReceive('info')->once();
+
+ $this->assertSame(0, $command->handle($manager, $config));
+ }
+
public function testOperationalCreationFailuresPropagate(): void
{
$failure = new RuntimeException('Search service unavailable.');
diff --git a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php
index 0c8b35bae..c42b7eb96 100644
--- a/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php
+++ b/tests/Scout/Unit/Console/SyncIndexSettingsCommandTest.php
@@ -5,11 +5,14 @@
namespace Hypervel\Tests\Scout\Unit\Console;
use Hypervel\Config\Repository;
+use Hypervel\Database\Eloquent\Model;
+use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Scout\Console\SyncIndexSettingsCommand;
use Hypervel\Scout\Contracts\UpdatesIndexSettings;
use Hypervel\Scout\EngineManager;
use Hypervel\Scout\Engines\CollectionEngine;
use Hypervel\Scout\Engines\Engine;
+use Hypervel\Scout\Scout;
use Hypervel\Tests\TestCase;
use Mockery as m;
use ReflectionMethod;
@@ -114,6 +117,59 @@ public function testSyncsIndexSettingsSuccessfully(): void
$this->assertSame(0, $result);
}
+ public function testLifecycleCallbackReceivesModelSettingsAfterSoftDeleteContribution(): void
+ {
+ $engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class);
+ $engine->shouldReceive('configureSoftDeleteFilter')
+ ->once()
+ ->with(['searchableAttributes' => ['title']])
+ ->andReturn([
+ 'searchableAttributes' => ['title'],
+ 'filterableAttributes' => ['__soft_deleted'],
+ ]);
+ $engine->shouldReceive('updateIndexSettings')
+ ->once()
+ ->with('soft_deletable_searchable_models', [
+ 'searchableAttributes' => ['title'],
+ 'filterableAttributes' => ['__soft_deleted', 'tenant_id'],
+ ]);
+ $manager = m::mock(EngineManager::class);
+ $manager->shouldReceive('engine')->with('meilisearch')->once()->andReturn($engine);
+ $config = m::mock(Repository::class);
+ $config->shouldReceive('string')->with('scout.driver')->andReturn('meilisearch');
+ $config->shouldReceive('array')
+ ->with('scout.meilisearch.index-settings', [])
+ ->andReturn([
+ SyncIndexSettingsSoftDeleteModel::class => ['searchableAttributes' => ['title']],
+ ]);
+ $config->shouldReceive('boolean')->with('scout.soft_delete', false)->andReturn(true);
+
+ Scout::prepareIndexSettingsUsing(function (
+ array $settings,
+ ?Model $model,
+ Engine $givenEngine,
+ string $index
+ ) use ($engine): array {
+ $this->assertInstanceOf(SyncIndexSettingsSoftDeleteModel::class, $model);
+ $this->assertSame($engine, $givenEngine);
+ $this->assertSame('soft_deletable_searchable_models', $index);
+ $this->assertSame([
+ 'searchableAttributes' => ['title'],
+ 'filterableAttributes' => ['__soft_deleted'],
+ ], $settings);
+
+ $settings['filterableAttributes'][] = 'tenant_id';
+
+ return $settings;
+ });
+
+ $command = m::mock(SyncIndexSettingsCommand::class)->makePartial();
+ $command->shouldReceive('option')->with('driver')->andReturn(null);
+ $command->shouldReceive('info')->once();
+
+ $this->assertSame(0, $command->handle($manager, $config));
+ }
+
public function testUsesDriverOptionWhenProvided(): void
{
$engine = m::mock(Engine::class . ', ' . UpdatesIndexSettings::class);
@@ -232,3 +288,13 @@ public function testIndexNameResolutionDoesNotDuplicatePrefix(): void
$this->assertSame('prod_posts', $result);
}
}
+
+class SyncIndexSettingsSoftDeleteModel extends Model
+{
+ use SoftDeletes;
+
+ public function indexableAs(): string
+ {
+ return 'soft_deletable_searchable_models';
+ }
+}
diff --git a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php
index d2fb98ec3..4f617e337 100644
--- a/tests/Scout/Unit/Engines/AlgoliaEngineTest.php
+++ b/tests/Scout/Unit/Engines/AlgoliaEngineTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Scout\Unit\Engines;
use Algolia\AlgoliaSearch\Api\SearchClient as AlgoliaSearchClient;
+use Algolia\AlgoliaSearch\Exceptions\NotFoundException;
use Hypervel\Context\RequestContext;
use Hypervel\Coroutine\WaitGroup;
use Hypervel\Database\Eloquent\Collection as EloquentCollection;
@@ -12,10 +13,14 @@
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Http\Request;
use Hypervel\Scout\Builder;
+use Hypervel\Scout\Contracts\DeletesByFilter;
use Hypervel\Scout\Contracts\SearchableInterface;
use Hypervel\Scout\Engines\AlgoliaEngine;
+use Hypervel\Scout\Engines\Engine;
use Hypervel\Scout\Exceptions\NotSupportedException;
+use Hypervel\Scout\Exceptions\ScoutException;
use Hypervel\Scout\Jobs\RemoveableScoutCollection;
+use Hypervel\Scout\Scout;
use Hypervel\Scout\Searchable;
use Hypervel\Support\LazyCollection;
use Hypervel\Tests\TestCase;
@@ -94,6 +99,44 @@ public function testUpdateIncludesScoutMetadata(): void
$engine->update(new EloquentCollection([$model]));
}
+ public function testUpdatePreparesTheFinalSearchableDocument(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldReceive('saveObjects')
+ ->once()
+ ->with('users', [[
+ 'name' => 'Taylor',
+ '__soft_deleted' => 0,
+ 'objectID' => 1,
+ 'tenant_id' => 42,
+ ]]);
+
+ $engine = new AlgoliaEngine($client);
+ $model = $this->createSearchableModelMock();
+ $model->shouldReceive('indexableAs')->andReturn('users');
+ $model->shouldReceive('toSearchableArray')->andReturn(['name' => 'Taylor']);
+ $model->shouldReceive('scoutMetadata')->andReturn(['__soft_deleted' => 0]);
+ $model->shouldReceive('getScoutKey')->andReturn(1);
+
+ Scout::prepareSearchableDocumentUsing(function (
+ array $document,
+ Model $givenModel,
+ Engine $givenEngine
+ ) use ($model, $engine): array {
+ $this->assertSame([
+ 'name' => 'Taylor',
+ '__soft_deleted' => 0,
+ 'objectID' => 1,
+ ], $document);
+ $this->assertSame($model, $givenModel);
+ $this->assertSame($engine, $givenEngine);
+
+ return [...$document, 'tenant_id' => 42];
+ });
+
+ $engine->update(new EloquentCollection([$model]));
+ }
+
public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata(): void
{
$client = m::mock(AlgoliaSearchClient::class);
@@ -218,6 +261,139 @@ public function testFlush(): void
$engine->flush($model);
}
+ public function testDeleteByFilterPreparesTheBuilderAndUsesTheDefaultWriteIndex(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldReceive('deleteBy')
+ ->once()
+ ->with('users_write', [
+ 'filters' => "(visibility:public) AND (status:'active' AND tenant_id:'42')",
+ ])
+ ->andReturn(['taskID' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with('users_write', 123)
+ ->andReturn(['status' => 'published']);
+
+ $engine = new AlgoliaEngine($client);
+ $this->assertInstanceOf(DeletesByFilter::class, $engine);
+
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldReceive('indexableAs')->once()->andReturn('users_write');
+ $model->shouldNotReceive('searchableAs');
+ $builder = (new Builder($model, ''))
+ ->options(['filters' => 'visibility:public'])
+ ->where('status', 'active');
+
+ Scout::prepareBuilderUsing(function (Builder $givenBuilder, Engine $givenEngine) use ($builder, $engine): void {
+ $this->assertSame($builder, $givenBuilder);
+ $this->assertSame($engine, $givenEngine);
+ $givenBuilder->where('tenant_id', 42);
+ });
+
+ $engine->deleteByFilter($builder);
+ }
+
+ public function testDeleteByFilterUsesAnExplicitIndex(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldReceive('deleteBy')
+ ->once()
+ ->with('users_v2', ['filters' => "tenant_id:'42'"])
+ ->andReturn(['taskID' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with('users_v2', 123)
+ ->andReturn(['status' => 'published']);
+
+ $engine = new AlgoliaEngine($client);
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldNotReceive('indexableAs');
+ $builder = (new Builder($model, ''))->within('users_v2')->where('tenant_id', 42);
+
+ $engine->deleteByFilter($builder);
+ }
+
+ public function testDeleteByFilterTreatsAMissingIndexAsAlreadyDeleted(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldReceive('deleteBy')
+ ->once()
+ ->with('users', ['filters' => "tenant_id:'42'"])
+ ->andThrow(new NotFoundException('Index not found', 404));
+ $client->shouldNotReceive('waitForTask');
+
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldReceive('indexableAs')->once()->andReturn('users');
+ $builder = (new Builder($model, ''))->where('tenant_id', 42);
+
+ (new AlgoliaEngine($client))->deleteByFilter($builder);
+
+ $this->assertTrue(true);
+ }
+
+ public function testDeleteByFilterRejectsAnEmptyFilterBeforeIo(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldNotReceive('deleteBy');
+ $client->shouldNotReceive('waitForTask');
+ $engine = new AlgoliaEngine($client);
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldNotReceive('indexableAs');
+ $builder = new Builder($model, '');
+ $prepared = false;
+
+ Scout::prepareBuilderUsing(function () use (&$prepared): void {
+ $prepared = true;
+ });
+
+ try {
+ $engine->deleteByFilter($builder);
+ $this->fail('Expected empty filter deletion to be rejected.');
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame('Algolia filter deletion requires a non-empty filter.', $exception->getMessage());
+ }
+
+ $this->assertTrue($prepared);
+ }
+
+ #[DataProvider('incompleteFilterDeletionTasks')]
+ public function testDeleteByFilterRejectsAnIncompleteTask(?array $task): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldReceive('deleteBy')
+ ->once()
+ ->with('users', ['filters' => "tenant_id:'42'"])
+ ->andReturn(['taskID' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with('users', 123)
+ ->andReturn($task);
+
+ $engine = new AlgoliaEngine($client);
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldReceive('indexableAs')->once()->andReturn('users');
+ $builder = (new Builder($model, ''))->where('tenant_id', 42);
+
+ $this->expectException(ScoutException::class);
+ $this->expectExceptionMessage('Algolia filter deletion did not complete successfully.');
+
+ $engine->deleteByFilter($builder);
+ }
+
+ /**
+ * Provide incomplete Algolia filter-deletion tasks.
+ *
+ * @return array
+ */
+ public static function incompleteFilterDeletionTasks(): array
+ {
+ return [
+ 'swallowed polling failure' => [null],
+ 'non-published task' => [['status' => 'processing']],
+ ];
+ }
+
public function testUpdateIndexSettings(): void
{
$client = m::mock(AlgoliaSearchClient::class);
@@ -259,6 +435,101 @@ public function testSearchSendsCorrectParameters(): void
$engine->search($builder);
}
+ #[DataProvider('filterCombinations')]
+ public function testSearchComposesApplicationAndBuilderFilters(
+ ?string $applicationFilters,
+ bool $hasBuilderFilter,
+ ?string $expectedFilters
+ ): void {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $expected = ['query' => 'zonda'];
+
+ if ($expectedFilters !== null) {
+ $expected['filters'] = $expectedFilters;
+ }
+
+ $client->shouldReceive('searchSingleIndex')
+ ->once()
+ ->with('users', $expected, []);
+
+ $engine = new AlgoliaEngine($client);
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldReceive('searchableAs')->andReturn('users');
+ $builder = new Builder($model, 'zonda');
+
+ if ($applicationFilters !== null) {
+ $builder->options(['filters' => $applicationFilters]);
+ }
+
+ if ($hasBuilderFilter) {
+ $builder->where('tenant_id', 42);
+ }
+
+ $engine->search($builder);
+ }
+
+ /**
+ * Provide application and Builder filter combinations.
+ *
+ * @return array
+ */
+ public static function filterCombinations(): array
+ {
+ return [
+ 'neither side' => [null, false, null],
+ 'application only' => ['status:active OR status:trial', false, 'status:active OR status:trial'],
+ 'builder only' => [null, true, "tenant_id:'42'"],
+ 'both sides' => [
+ 'status:active OR status:trial',
+ true,
+ "(status:active OR status:trial) AND (tenant_id:'42')",
+ ],
+ ];
+ }
+
+ public function testPaginateComposesApplicationAndBuilderFilters(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldReceive('searchSingleIndex')
+ ->once()
+ ->with('users', [
+ 'query' => 'zonda',
+ 'filters' => "(status:active OR status:trial) AND (tenant_id:'42')",
+ 'hitsPerPage' => 15,
+ 'page' => 1,
+ ], []);
+
+ $engine = new AlgoliaEngine($client);
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldReceive('searchableAs')->andReturn('users');
+ $builder = (new Builder($model, 'zonda'))
+ ->options(['filters' => 'status:active OR status:trial'])
+ ->where('tenant_id', 42);
+
+ $engine->paginate($builder, 15, 2);
+ }
+
+ public function testSearchCallbackReceivesFinalComposedOptions(): void
+ {
+ $client = m::mock(AlgoliaSearchClient::class);
+ $client->shouldNotReceive('searchSingleIndex');
+ $engine = new AlgoliaEngine($client);
+ $model = m::mock(AlgoliaTestSearchableModel::class);
+ $model->shouldReceive('searchableAs')->andReturn('users');
+ $builder = new Builder($model, 'zonda', function ($givenClient, $query, $options) use ($client) {
+ $this->assertSame($client, $givenClient);
+ $this->assertSame('zonda', $query);
+ $this->assertSame([
+ 'filters' => "(status:active OR status:trial) AND (tenant_id:'42')",
+ ], $options);
+
+ return ['hits' => [], 'nbHits' => 0];
+ });
+ $builder->options(['filters' => 'status:active OR status:trial'])->where('tenant_id', 42);
+
+ $engine->search($builder);
+ }
+
public function testSearchSendsBooleanInequalityAndEscapedFilters(): void
{
$client = m::mock(AlgoliaSearchClient::class);
diff --git a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php
index c9b185d32..b3b92f68f 100644
--- a/tests/Scout/Unit/Engines/MeilisearchEngineTest.php
+++ b/tests/Scout/Unit/Engines/MeilisearchEngineTest.php
@@ -4,22 +4,30 @@
namespace Hypervel\Tests\Scout\Unit\Engines;
+use DateTimeImmutable;
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\DeletesByFilter;
use Hypervel\Scout\Contracts\SearchableInterface;
+use Hypervel\Scout\Engines\Engine;
use Hypervel\Scout\Engines\MeilisearchEngine;
+use Hypervel\Scout\Exceptions\ScoutException;
use Hypervel\Scout\Jobs\RemoveableScoutCollection;
+use Hypervel\Scout\Scout;
use Hypervel\Scout\Searchable;
use Hypervel\Support\LazyCollection;
use Hypervel\Tests\TestCase;
+use InvalidArgumentException;
use Meilisearch\Client;
use Meilisearch\Contracts\IndexesResults;
use Meilisearch\Endpoints\Indexes;
use Meilisearch\Exceptions\ApiException;
+use Meilisearch\Exceptions\TimeOutException;
use Mockery as m;
+use PHPUnit\Framework\Attributes\DataProvider;
class MeilisearchEngineTest extends TestCase
{
@@ -91,6 +99,47 @@ public function testUpdateWithSoftDeletesAddsSoftDeleteMetadata(): void
$engine->update(new EloquentCollection([$model]));
}
+ public function testUpdatePreparesTheFinalSearchableDocument(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('test_index')->andReturn($index);
+ $index->shouldReceive('addDocuments')
+ ->once()
+ ->with([[
+ 'title' => 'Test',
+ '__soft_deleted' => 0,
+ 'id' => 1,
+ 'tenant_id' => 42,
+ ]], 'id');
+
+ $engine = new MeilisearchEngine($client);
+ $model = $this->createSearchableModelMock();
+ $model->shouldReceive('indexableAs')->andReturn('test_index');
+ $model->shouldReceive('toSearchableArray')->andReturn(['title' => 'Test']);
+ $model->shouldReceive('scoutMetadata')->andReturn(['__soft_deleted' => 0]);
+ $model->shouldReceive('getScoutKeyName')->andReturn('id');
+ $model->shouldReceive('getScoutKey')->andReturn(1);
+
+ Scout::prepareSearchableDocumentUsing(function (
+ array $document,
+ Model $givenModel,
+ Engine $givenEngine
+ ) use ($model, $engine): array {
+ $this->assertSame([
+ 'title' => 'Test',
+ '__soft_deleted' => 0,
+ 'id' => 1,
+ ], $document);
+ $this->assertSame($model, $givenModel);
+ $this->assertSame($engine, $givenEngine);
+
+ return [...$document, 'tenant_id' => 42];
+ });
+
+ $engine->update(new EloquentCollection([$model]));
+ }
+
public function testDeleteRemovesDocumentsFromIndex(): void
{
$client = m::mock(Client::class);
@@ -212,6 +261,111 @@ public function testSearchWithFilters(): void
$engine->search($builder);
}
+ #[DataProvider('filterCombinations')]
+ public function testSearchComposesApplicationAndBuilderFilters(
+ string|array|null $applicationFilter,
+ bool $hasBuilderFilter,
+ string|array|null $expectedFilter
+ ): void {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('test_index')->andReturn($index);
+ $expected = $expectedFilter === null ? [] : ['filter' => $expectedFilter];
+ $index->shouldReceive('rawSearch')->once()->with('query', $expected);
+
+ $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');
+
+ if ($applicationFilter !== null) {
+ $builder->options(['filter' => $applicationFilter]);
+ }
+
+ if ($hasBuilderFilter) {
+ $builder->where('tenant_id', 42);
+ }
+
+ $engine->search($builder);
+ }
+
+ /**
+ * Provide application and Builder filter combinations.
+ *
+ * @return array|string, bool, null|array|string}>
+ */
+ public static function filterCombinations(): array
+ {
+ return [
+ 'neither side' => [null, false, null],
+ 'application string only' => ['status="active" OR status="trial"', false, 'status="active" OR status="trial"'],
+ 'whitespace application and builder' => [' ', true, 'tenant_id=42'],
+ 'builder only' => [null, true, 'tenant_id=42'],
+ 'both string sides' => [
+ 'status="active" OR status="trial"',
+ true,
+ '(status="active" OR status="trial") AND (tenant_id=42)',
+ ],
+ 'application array only' => [
+ [['status="active"', 'status="trial"'], 'visible=true'],
+ false,
+ [['status="active"', 'status="trial"'], 'visible=true'],
+ ],
+ 'application array and builder' => [
+ [['status="active"', 'status="trial"'], 'visible=true'],
+ true,
+ [['status="active"', 'status="trial"'], 'visible=true', 'tenant_id=42'],
+ ],
+ ];
+ }
+
+ public function testPaginateComposesApplicationAndBuilderFilters(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('test_index')->andReturn($index);
+ $index->shouldReceive('rawSearch')->once()->with('query', [
+ 'filter' => '(status="active" OR status="trial") AND (tenant_id=42)',
+ 'hitsPerPage' => 15,
+ 'page' => 2,
+ ]);
+
+ $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'))
+ ->options(['filter' => 'status="active" OR status="trial"'])
+ ->where('tenant_id', 42);
+
+ $engine->paginate($builder, 15, 2);
+ }
+
+ public function testSearchCallbackReceivesFinalComposedOptions(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('test_index')->andReturn($index);
+ $index->shouldNotReceive('rawSearch');
+ $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', function ($givenIndex, $query, $options) use ($index) {
+ $this->assertSame($index, $givenIndex);
+ $this->assertSame('query', $query);
+ $this->assertSame([
+ 'filter' => [['status="active"', 'status="trial"'], 'tenant_id=42'],
+ ], $options);
+
+ return ['hits' => [], 'totalHits' => 0];
+ });
+ $builder->options(['filter' => [['status="active"', 'status="trial"']]])->where('tenant_id', 42);
+
+ $engine->search($builder);
+ }
+
public function testSearchWithBackedEnumFilters(): void
{
$client = m::mock(Client::class);
@@ -448,6 +602,155 @@ public function testFlushDeletesAllDocuments(): void
$engine->flush($model);
}
+ public function testDeleteByFilterPreparesTheBuilderAndWaitsForTheDefaultWriteIndex(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('users_write')->andReturn($index);
+ $index->shouldReceive('deleteDocuments')
+ ->once()
+ ->with(['filter' => [['visibility=true', 'visibility=false'], 'status="active" AND tenant_id=42']])
+ ->andReturn(['taskUid' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with(123, 500_000, 5_000)
+ ->andReturn(['status' => 'succeeded']);
+
+ $engine = new MeilisearchEngine($client);
+ $this->assertInstanceOf(DeletesByFilter::class, $engine);
+
+ $model = m::mock(MeilisearchTestSearchableModel::class);
+ $model->shouldReceive('indexableAs')->once()->andReturn('users_write');
+ $model->shouldNotReceive('searchableAs');
+ $builder = (new Builder($model, ''))
+ ->options(['filter' => [['visibility=true', 'visibility=false']]])
+ ->where('status', 'active');
+
+ Scout::prepareBuilderUsing(function (Builder $givenBuilder, Engine $givenEngine) use ($builder, $engine): void {
+ $this->assertSame($builder, $givenBuilder);
+ $this->assertSame($engine, $givenEngine);
+ $givenBuilder->where('tenant_id', 42);
+ });
+
+ $engine->deleteByFilter($builder);
+ }
+
+ public function testDeleteByFilterUsesAnExplicitIndex(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('users_v2')->andReturn($index);
+ $index->shouldReceive('deleteDocuments')
+ ->once()
+ ->with(['filter' => 'tenant_id=42'])
+ ->andReturn(['taskUid' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with(123, 500_000, 5_000)
+ ->andReturn(['status' => 'succeeded']);
+
+ $engine = new MeilisearchEngine($client);
+ $model = m::mock(MeilisearchTestSearchableModel::class);
+ $model->shouldNotReceive('indexableAs');
+ $builder = (new Builder($model, ''))->within('users_v2')->where('tenant_id', 42);
+
+ $engine->deleteByFilter($builder);
+ }
+
+ public function testDeleteByFilterTreatsAMissingIndexAsAlreadyDeleted(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('users')->andReturn($index);
+ $index->shouldReceive('deleteDocuments')
+ ->once()
+ ->with(['filter' => 'tenant_id=42'])
+ ->andReturn(['taskUid' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with(123, 500_000, 5_000)
+ ->andReturn([
+ 'status' => 'failed',
+ 'error' => ['code' => 'index_not_found'],
+ ]);
+
+ $model = m::mock(MeilisearchTestSearchableModel::class);
+ $model->shouldReceive('indexableAs')->once()->andReturn('users');
+ $builder = (new Builder($model, ''))->where('tenant_id', 42);
+
+ (new MeilisearchEngine($client))->deleteByFilter($builder);
+
+ $this->assertTrue(true);
+ }
+
+ public function testDeleteByFilterRejectsAnEmptyFilterBeforeIo(): void
+ {
+ $client = m::mock(Client::class);
+ $client->shouldNotReceive('index');
+ $client->shouldNotReceive('waitForTask');
+ $engine = new MeilisearchEngine($client);
+ $model = m::mock(MeilisearchTestSearchableModel::class);
+ $model->shouldNotReceive('indexableAs');
+ $builder = new Builder($model, '');
+ $prepared = false;
+ Scout::prepareBuilderUsing(function () use (&$prepared): void {
+ $prepared = true;
+ });
+
+ try {
+ $engine->deleteByFilter($builder);
+ $this->fail('Expected empty filter deletion to be rejected.');
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame('Meilisearch filter deletion requires a non-empty filter.', $exception->getMessage());
+ }
+
+ $this->assertTrue($prepared);
+ }
+
+ public function testDeleteByFilterRejectsAnUnsuccessfulTask(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $client->shouldReceive('index')->once()->with('users')->andReturn($index);
+ $index->shouldReceive('deleteDocuments')->once()->andReturn(['taskUid' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with(123, 500_000, 5_000)
+ ->andReturn(['status' => 'failed']);
+
+ $engine = new MeilisearchEngine($client);
+ $model = m::mock(MeilisearchTestSearchableModel::class);
+ $model->shouldReceive('indexableAs')->andReturn('users');
+ $builder = (new Builder($model, ''))->where('tenant_id', 42);
+
+ $this->expectException(ScoutException::class);
+ $this->expectExceptionMessage('Meilisearch filter deletion did not complete successfully.');
+
+ $engine->deleteByFilter($builder);
+ }
+
+ public function testDeleteByFilterPreservesSdkTimeouts(): void
+ {
+ $client = m::mock(Client::class);
+ $index = m::mock(Indexes::class);
+ $timeout = new TimeOutException('Timed out waiting for deletion.');
+ $client->shouldReceive('index')->once()->with('users')->andReturn($index);
+ $index->shouldReceive('deleteDocuments')->once()->andReturn(['taskUid' => 123]);
+ $client->shouldReceive('waitForTask')
+ ->once()
+ ->with(123, 500_000, 5_000)
+ ->andThrow($timeout);
+
+ $engine = new MeilisearchEngine($client);
+ $model = m::mock(MeilisearchTestSearchableModel::class);
+ $model->shouldReceive('indexableAs')->andReturn('users');
+ $builder = (new Builder($model, ''))->where('tenant_id', 42);
+
+ $this->expectExceptionObject($timeout);
+
+ $engine->deleteByFilter($builder);
+ }
+
public function testCreateIndexCreatesNewIndex(): void
{
$client = m::mock(Client::class);
@@ -759,6 +1062,66 @@ public function testGetMeilisearchClientReturnsClient(): void
$this->assertSame($client, $engine->getMeilisearchClient());
}
+ public function testGenerateTenantTokenUsesTheExplicitParentIdentityAndSigner(): void
+ {
+ $client = new Client('http://localhost:7700', 'different-client-key');
+ $engine = new MeilisearchEngine($client);
+ $expiresAt = new DateTimeImmutable('+1 hour');
+ $apiKey = 'explicit-parent-key';
+
+ $token = $engine->generateTenantToken(
+ ['users' => ['filter' => 'tenant_id = 42']],
+ 'parent-key-uid',
+ $apiKey,
+ $expiresAt,
+ );
+ [$encodedHeader, $encodedPayload, $signature] = explode('.', $token);
+ $payload = json_decode(base64_decode(strtr($encodedPayload, '-_', '+/')), true, flags: JSON_THROW_ON_ERROR);
+ $signedContent = $encodedHeader . '.' . $encodedPayload;
+ $expectedSignature = rtrim(strtr(base64_encode(
+ hash_hmac('sha256', $signedContent, $apiKey, true)
+ ), '+/', '-_'), '=');
+ $clientKeySignature = rtrim(strtr(base64_encode(
+ hash_hmac('sha256', $signedContent, 'different-client-key', true)
+ ), '+/', '-_'), '=');
+
+ $this->assertSame('parent-key-uid', $payload['apiKeyUid']);
+ $this->assertSame(['users' => ['filter' => 'tenant_id = 42']], $payload['searchRules']);
+ $this->assertSame($expiresAt->getTimestamp(), $payload['exp']);
+ $this->assertSame($expectedSignature, $signature);
+ $this->assertNotSame($clientKeySignature, $signature);
+ }
+
+ #[DataProvider('emptyTenantTokenCredentials')]
+ public function testGenerateTenantTokenRejectsEmptyCredentials(
+ string $apiKeyUid,
+ string $apiKey,
+ string $message
+ ): void {
+ $client = m::mock(Client::class);
+ $client->shouldNotReceive('generateTenantToken');
+ $client->shouldNotReceive('getKeys');
+ $engine = new MeilisearchEngine($client);
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+
+ $engine->generateTenantToken(['users' => ['filter' => 'tenant_id = 42']], $apiKeyUid, $apiKey);
+ }
+
+ /**
+ * Provide empty Meilisearch tenant-token credentials.
+ *
+ * @return array
+ */
+ public static function emptyTenantTokenCredentials(): array
+ {
+ return [
+ 'empty UID' => ['', 'explicit-parent-key', 'Meilisearch tenant tokens require a non-empty API key UID.'],
+ 'empty key' => ['parent-key-uid', '', 'Meilisearch tenant tokens require a non-empty API key.'],
+ ];
+ }
+
protected function apiException(int $status, string $code): ApiException
{
return new ApiException(new Response($status), [
diff --git a/tests/Scout/Unit/Engines/TypesenseEngineTest.php b/tests/Scout/Unit/Engines/TypesenseEngineTest.php
index ec8c2954f..af8738ed7 100644
--- a/tests/Scout/Unit/Engines/TypesenseEngineTest.php
+++ b/tests/Scout/Unit/Engines/TypesenseEngineTest.php
@@ -7,10 +7,13 @@
use Hypervel\Database\Eloquent\Collection as EloquentCollection;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Scout\Builder;
+use Hypervel\Scout\Contracts\DeletesByFilter;
use Hypervel\Scout\Contracts\SearchableInterface;
+use Hypervel\Scout\Engines\Engine;
use Hypervel\Scout\Engines\TypesenseEngine;
use Hypervel\Scout\Exceptions\NotSupportedException;
use Hypervel\Scout\Jobs\RemoveableScoutCollection;
+use Hypervel\Scout\Scout;
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
use Mockery as m;
@@ -238,6 +241,38 @@ public function testUpdateImportsIntoIndexableCollection(): void
$this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model]));
}
+ public function testUpdatePreparesTheFinalSearchableDocument(): 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();
+ $collection->shouldReceive('getDocuments')->once()->andReturn($documents);
+ $documents->shouldReceive('import')
+ ->once()
+ ->with([['id' => 1, 'title' => 'Scout', 'tenant_id' => 42]], ['action' => 'upsert'])
+ ->andReturn([['success' => true, 'document' => '{"id":1}']]);
+
+ $model = new TypesenseLifecycleModel;
+ $engine = $this->createPartialEngineWithConfig($client);
+ Scout::prepareSearchableDocumentUsing(function (
+ array $document,
+ Model $givenModel,
+ Engine $givenEngine
+ ) use ($model, $engine): array {
+ $this->assertSame(['id' => 1, 'title' => 'Scout'], $document);
+ $this->assertSame($model, $givenModel);
+ $this->assertSame($engine, $givenEngine);
+
+ return [...$document, 'tenant_id' => 42];
+ });
+
+ $engine->update(new EloquentCollection([$model]));
+ }
+
public function testUpdateCreatesMissingCollectionAndRetriesImportOnce(): void
{
$client = m::mock(TypesenseClient::class);
@@ -270,6 +305,56 @@ public function testUpdateCreatesMissingCollectionAndRetriesImportOnce(): void
$this->createPartialEngineWithConfig($client)->update(new EloquentCollection([$model]));
}
+ public function testMissingCollectionPreparesSettingsBeforeTheAuthoritativeName(): 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' => 'tenant_id', 'type' => 'int64'],
+ ],
+ '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;
+ $engine = $this->createPartialEngineWithConfig($client);
+ Scout::prepareIndexSettingsUsing(function (
+ array $settings,
+ ?Model $givenModel,
+ Engine $givenEngine,
+ string $index
+ ) use ($model, $engine): array {
+ $this->assertSame([
+ 'fields' => [['name' => 'title', 'type' => 'string']],
+ ], $settings);
+ $this->assertSame($model, $givenModel);
+ $this->assertSame($engine, $givenEngine);
+ $this->assertSame('write_index', $index);
+
+ return [
+ ...$settings,
+ 'fields' => [...$settings['fields'], ['name' => 'tenant_id', 'type' => 'int64']],
+ 'name' => 'callback_attempted_override',
+ ];
+ });
+
+ $engine->update(new EloquentCollection([$model]));
+ }
+
public function testUpdateAcceptsOnlyCollectionAlreadyExistsCreateRace(): void
{
$client = m::mock(TypesenseClient::class);
@@ -501,6 +586,102 @@ public function testFlushTreatsMissingCollectionAsAlreadyFlushed(): void
$this->createEngine($client)->flush($model);
}
+ public function testDeleteByFilterPreparesTheBuilderAndUsesTheDefaultWriteCollection(): void
+ {
+ $documents = m::mock(Documents::class);
+ $documents->shouldReceive('delete')
+ ->once()
+ ->with(['filter_by' => '(status:=active || status:=trial) && (tenant_id:=42)'])
+ ->andReturn(['num_deleted' => 2]);
+ $collection = m::mock(TypesenseCollection::class);
+ $collection->shouldReceive('getDocuments')->once()->andReturn($documents);
+ $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->searchParameters = ['filter_by' => 'from_model:=true'];
+ $builder = (new Builder($model, ''))
+ ->options(['filter_by' => 'status:=active || status:=trial']);
+ $engine = $this->createPartialEngineWithConfig($client);
+ $this->assertInstanceOf(DeletesByFilter::class, $engine);
+ Scout::prepareBuilderUsing(function (Builder $givenBuilder, Engine $givenEngine) use ($builder, $engine): void {
+ $this->assertSame($builder, $givenBuilder);
+ $this->assertSame($engine, $givenEngine);
+ $givenBuilder->where('tenant_id', 42);
+ });
+
+ $engine->deleteByFilter($builder);
+ }
+
+ public function testDeleteByFilterUsesAnExplicitCollection(): void
+ {
+ $documents = m::mock(Documents::class);
+ $documents->shouldReceive('delete')
+ ->once()
+ ->with(['filter_by' => 'tenant_id:=42'])
+ ->andReturn(['num_deleted' => 1]);
+ $collection = m::mock(TypesenseCollection::class);
+ $collection->shouldReceive('getDocuments')->once()->andReturn($documents);
+ $collections = m::mock(Collections::class);
+ $collections->shouldReceive('offsetGet')->with('users_v2')->once()->andReturn($collection);
+ $collections->shouldReceive('offsetUnset')->with('users_v2')->once();
+ $client = m::mock(TypesenseClient::class);
+ $client->shouldReceive('getCollections')->once()->andReturn($collections);
+
+ $model = new TypesenseLifecycleModel;
+ $builder = (new Builder($model, ''))->within('users_v2')->where('tenant_id', 42);
+
+ $this->createPartialEngineWithConfig($client)->deleteByFilter($builder);
+ }
+
+ public function testDeleteByFilterTreatsAMissingCollectionAsAlreadyDeleted(): void
+ {
+ $documents = m::mock(Documents::class);
+ $documents->shouldReceive('delete')
+ ->once()
+ ->with(['filter_by' => 'tenant_id:=42'])
+ ->andThrow(new ObjectNotFound('Collection not found'));
+ $collection = m::mock(TypesenseCollection::class);
+ $collection->shouldReceive('getDocuments')->once()->andReturn($documents);
+ $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;
+ $builder = (new Builder($model, ''))->where('tenant_id', 42);
+
+ $this->createPartialEngineWithConfig($client)->deleteByFilter($builder);
+
+ $this->assertTrue(true);
+ }
+
+ public function testDeleteByFilterRejectsAnEmptyFilterBeforeIo(): void
+ {
+ $client = m::mock(TypesenseClient::class);
+ $client->shouldNotReceive('getCollections');
+ $model = new TypesenseLifecycleModel;
+ $builder = new Builder($model, '');
+ $engine = $this->createPartialEngineWithConfig($client);
+ $prepared = false;
+ Scout::prepareBuilderUsing(function () use (&$prepared): void {
+ $prepared = true;
+ });
+
+ try {
+ $engine->deleteByFilter($builder);
+ $this->fail('Expected empty filter deletion to be rejected.');
+ } catch (InvalidArgumentException $exception) {
+ $this->assertSame('Typesense filter deletion requires a non-empty filter.', $exception->getMessage());
+ }
+
+ $this->assertTrue($prepared);
+ }
+
public function testDeleteIndexCallsTypesenseDelete(): void
{
$collection = m::mock(TypesenseCollection::class);
@@ -711,6 +892,45 @@ public function testBuildSearchParametersIncludesFilters(): void
$this->assertStringContainsString('brand:!=[x]', $params['filter_by']);
}
+ public function testBuildSearchParametersComposesTheWinningApplicationFilterWithBuilderFilters(): void
+ {
+ $engine = $this->createPartialEngineWithConfig();
+ $model = new TypesenseLifecycleModel;
+ $model->searchParameters = ['filter_by' => 'from_model:=true'];
+ $builder = (new Builder($model, 'test'))
+ ->options(['filter_by' => 'status:=active || status:=trial'])
+ ->where('tenant_id', 42);
+
+ $parameters = $engine->buildSearchParameters($builder, 1, 10);
+
+ $this->assertSame('(status:=active || status:=trial) && (tenant_id:=42)', $parameters['filter_by']);
+ }
+
+ public function testSearchCallbackReceivesFinalComposedOptions(): 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('read_index')->once()->andReturn($collection);
+ $collections->shouldReceive('offsetUnset')->with('read_index')->once();
+ $collection->shouldReceive('getDocuments')->once()->andReturn($documents);
+ $documents->shouldNotReceive('search');
+
+ $model = new TypesenseLifecycleModel;
+ $builder = new Builder($model, 'scout', function ($givenDocuments, $query, $options) use ($documents) {
+ $this->assertSame($documents, $givenDocuments);
+ $this->assertSame('scout', $query);
+ $this->assertSame('(status:=active || status:=trial) && (tenant_id:=42)', $options['filter_by']);
+
+ return ['found' => 0, 'hits' => []];
+ });
+ $builder->options(['filter_by' => 'status:=active || status:=trial'])->where('tenant_id', 42);
+
+ $this->createPartialEngineWithConfig($client)->search($builder);
+ }
+
public function testBuildSearchParametersMergesBuilderOptions(): void
{
$engine = $this->createPartialEngineWithConfig();
@@ -855,6 +1075,9 @@ class TypesenseLifecycleModel extends Model
/** @var array */
public array $searchableData = ['id' => 1, 'title' => 'Scout'];
+ /** @var array */
+ public array $searchParameters = [];
+
public function toSearchableArray(): array
{
return $this->searchableData;
@@ -892,6 +1115,11 @@ public function typesenseCollectionSchema(): array
'fields' => [['name' => 'title', 'type' => 'string']],
];
}
+
+ public function typesenseSearchParameters(): array
+ {
+ return $this->searchParameters;
+ }
}
enum TypesenseStringCategory: string
diff --git a/tests/Scout/Unit/ScoutTest.php b/tests/Scout/Unit/ScoutTest.php
index be4a79404..b3a4fd567 100644
--- a/tests/Scout/Unit/ScoutTest.php
+++ b/tests/Scout/Unit/ScoutTest.php
@@ -4,6 +4,8 @@
namespace Hypervel\Tests\Scout\Unit;
+use Hypervel\Database\Eloquent\Model;
+use Hypervel\Scout\Builder;
use Hypervel\Scout\EngineManager;
use Hypervel\Scout\Engines\Engine;
use Hypervel\Scout\Jobs\MakeSearchable;
@@ -11,6 +13,7 @@
use Hypervel\Scout\Scout;
use Hypervel\Tests\Scout\ScoutTestCase;
use Mockery as m;
+use RuntimeException;
/**
* Tests for the Scout utility class.
@@ -41,15 +44,130 @@ public function testRemoveFromSearchUsingChangesJobClass(): void
$this->assertSame(CustomRemoveFromSearch::class, Scout::$removeFromSearchJob);
}
+ public function testLifecycleCallbacksPreserveValuesWhenUnset(): void
+ {
+ $builder = m::mock(Builder::class);
+ $engine = m::mock(Engine::class);
+ $model = m::mock(Model::class);
+
+ Scout::prepareBuilder($builder, $engine);
+
+ $this->assertSame(['name' => 'Taylor'], Scout::prepareSearchableDocument(
+ ['name' => 'Taylor'],
+ $model,
+ $engine
+ ));
+ $this->assertSame(['filterableAttributes' => ['status']], Scout::prepareIndexSettings(
+ ['filterableAttributes' => ['status']],
+ $model,
+ $engine,
+ 'users'
+ ));
+
+ Scout::guardModelFlush($model, $engine, false);
+ }
+
+ public function testLifecycleCallbacksReceiveTheirCompleteBoundaries(): void
+ {
+ $builder = m::mock(Builder::class);
+ $engine = m::mock(Engine::class);
+ $model = m::mock(Model::class);
+ $preparedBuilder = false;
+ $guardedFlush = false;
+
+ Scout::prepareBuilderUsing(function (Builder $givenBuilder, Engine $givenEngine) use (
+ $builder,
+ $engine,
+ &$preparedBuilder
+ ): void {
+ $this->assertSame($builder, $givenBuilder);
+ $this->assertSame($engine, $givenEngine);
+ $preparedBuilder = true;
+ });
+ Scout::prepareSearchableDocumentUsing(function (
+ array $document,
+ Model $givenModel,
+ Engine $givenEngine
+ ) use ($model, $engine): array {
+ $this->assertSame($model, $givenModel);
+ $this->assertSame($engine, $givenEngine);
+
+ return [...$document, 'prepared' => true];
+ });
+ Scout::prepareIndexSettingsUsing(function (
+ array $settings,
+ ?Model $givenModel,
+ Engine $givenEngine,
+ string $index
+ ) use ($model, $engine): array {
+ $this->assertSame($model, $givenModel);
+ $this->assertSame($engine, $givenEngine);
+ $this->assertSame('users', $index);
+
+ return [...$settings, 'prepared' => true];
+ });
+ Scout::guardModelFlushUsing(function (
+ Model $givenModel,
+ Engine $givenEngine,
+ bool $force
+ ) use ($model, $engine, &$guardedFlush): void {
+ $this->assertSame($model, $givenModel);
+ $this->assertSame($engine, $givenEngine);
+ $this->assertTrue($force);
+ $guardedFlush = true;
+ });
+
+ Scout::prepareBuilder($builder, $engine);
+ $document = Scout::prepareSearchableDocument(['name' => 'Taylor'], $model, $engine);
+ $settings = Scout::prepareIndexSettings(['searchableAttributes' => ['name']], $model, $engine, 'users');
+ Scout::guardModelFlush($model, $engine, true);
+
+ $this->assertTrue($preparedBuilder);
+ $this->assertSame(['name' => 'Taylor', 'prepared' => true], $document);
+ $this->assertSame(['searchableAttributes' => ['name'], 'prepared' => true], $settings);
+ $this->assertTrue($guardedFlush);
+ }
+
+ public function testRegisteringLifecycleCallbackAgainReplacesPreviousCallback(): void
+ {
+ $builder = m::mock(Builder::class);
+ $engine = m::mock(Engine::class);
+ $calls = [];
+
+ Scout::prepareBuilderUsing(function () use (&$calls): void {
+ $calls[] = 'first';
+ });
+ Scout::prepareBuilderUsing(function () use (&$calls): void {
+ $calls[] = 'second';
+ });
+
+ Scout::prepareBuilder($builder, $engine);
+
+ $this->assertSame(['second'], $calls);
+ }
+
public function testFlushStateRestoresDefaults(): void
{
Scout::makeSearchableUsing(CustomMakeSearchable::class);
Scout::removeFromSearchUsing(CustomRemoveFromSearch::class);
+ Scout::prepareBuilderUsing(fn () => throw new RuntimeException('Builder callback was not flushed.'));
+ Scout::prepareSearchableDocumentUsing(fn () => throw new RuntimeException('Document callback was not flushed.'));
+ Scout::prepareIndexSettingsUsing(fn () => throw new RuntimeException('Settings callback was not flushed.'));
+ Scout::guardModelFlushUsing(fn () => throw new RuntimeException('Flush callback was not flushed.'));
Scout::flushState();
$this->assertSame(MakeSearchable::class, Scout::$makeSearchableJob);
$this->assertSame(RemoveFromSearch::class, Scout::$removeFromSearchJob);
+
+ $builder = m::mock(Builder::class);
+ $engine = m::mock(Engine::class);
+ $model = m::mock(Model::class);
+
+ Scout::prepareBuilder($builder, $engine);
+ $this->assertSame([], Scout::prepareSearchableDocument([], $model, $engine));
+ $this->assertSame([], Scout::prepareIndexSettings([], null, $engine, 'users'));
+ Scout::guardModelFlush($model, $engine, false);
}
public function testEngineMethodReturnsEngineFromManager(): void