From 2164bce849c26d1146a81f9bdb280929b4e93f7e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:22:41 +0000 Subject: [PATCH 01/13] Add Scout lifecycle callbacks Add replacement-based, boot-time callbacks for builder preparation, searchable documents, index settings, and model-wide flush guards. Keep callback state bounded to four nullable slots, reset it through Scout::flushState(), and share constants between Scout's job defaults and their reset path. Cover unset behavior, complete callback arguments, replacement semantics, and state cleanup. --- src/scout/src/Scout.php | 170 ++++++++++++++++++++++++++++++--- tests/Scout/Unit/ScoutTest.php | 118 +++++++++++++++++++++++ 2 files changed, 275 insertions(+), 13 deletions(-) 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/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 From 726eeffebfdd4d60b0681e71f2ef2bdc86be8e89 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:22:48 +0000 Subject: [PATCH 02/13] Prepare Scout builders at terminal execution Resolve and prepare the selected engine at each outer Builder terminal while leaving Builder construction and configuration side-effect free. Keep pagination preparation single-shot by retaining raw engine resolution in the internal total-count path. Cover result terminals, pagination terminals, ordering, and the no-work construction boundary. --- src/scout/src/Builder.php | 30 ++++++++---- tests/Scout/Unit/BuilderTest.php | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 9 deletions(-) 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/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 () { From 271c40e142828526019e2c8646d4295b2b3e83c3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:22:57 +0000 Subject: [PATCH 03/13] Guard model-wide Scout flushes Run the Scout model-flush callback against the resolved engine before deleting an entire model index. Add an optional force argument without changing the default call shape. Mark the explicit scout:flush command as forced while keeping scout:import --fresh on the guarded default path. Cover callback ordering and both command behaviors. --- src/scout/src/Console/FlushCommand.php | 2 +- src/scout/src/Searchable.php | 8 +++-- tests/Scout/Feature/SearchableModelTest.php | 29 ++++++++++++++++++ tests/Scout/Unit/Console/FlushCommandTest.php | 30 +++++++++++++++++++ .../Scout/Unit/Console/ImportCommandTest.php | 9 ++++++ 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 tests/Scout/Unit/Console/FlushCommandTest.php 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/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/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/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 From 6f04d46ebc79f25d4b25c1c6230f04d8a479b752 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:23:05 +0000 Subject: [PATCH 04/13] Prepare Scout index settings before writes Invoke the Scout settings lifecycle after application and soft-delete settings are assembled and after the physical index name is known. Support both model-backed and raw named settings entries, including callbacks that turn an empty configured entry into concrete settings. Cover command inputs, ordering, nullable model context, and the final index target. --- src/scout/src/Console/IndexCommand.php | 5 ++ .../src/Console/SyncIndexSettingsCommand.php | 4 ++ tests/Scout/Unit/Console/IndexCommandTest.php | 37 +++++++++++ .../Console/SyncIndexSettingsCommandTest.php | 66 +++++++++++++++++++ 4 files changed, 112 insertions(+) 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/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'; + } +} From 18d3ce71ecec03129272cc32ab1bbb4393946abc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:23:24 +0000 Subject: [PATCH 05/13] Add Algolia filter maintenance Introduce the optional DeletesByFilter engine capability and implement it for Algolia with prepared builders, explicit write-target selection, empty-filter refusal, missing-index handling, and verified task completion. Preserve caller-authored filters when Builder constraints are present by composing both expressions with explicit precedence. Run final documents through Scout's lifecycle boundary and cover search, pagination, callbacks, deletion failures, and the real service path. --- src/scout/src/Contracts/DeletesByFilter.php | 23 ++ src/scout/src/Engines/AlgoliaEngine.php | 68 ++++- .../AlgoliaFilteringIntegrationTest.php | 28 ++ .../Scout/Unit/Engines/AlgoliaEngineTest.php | 271 ++++++++++++++++++ 4 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 src/scout/src/Contracts/DeletesByFilter.php 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/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/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); From d037d9ab68456c766ad4a5cb9f9124adc8a01047 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:23:33 +0000 Subject: [PATCH 06/13] Add Meilisearch filter maintenance Compose application filters with Builder constraints across string and array filter forms, prepare final documents, and add completion-aware filtered deletion with bounded polling and precise missing-index handling. Replace network key discovery in token generation with explicit parent-key identity and local signing so credentials cannot mix identifiers and secrets. Cover option precedence, filter shapes, task failures, timeouts, target selection, and real-service signing and deletion. --- src/scout/src/Engines/MeilisearchEngine.php | 131 +++++-- .../MeilisearchEngineIntegrationTest.php | 53 +++ .../MeilisearchFilteringIntegrationTest.php | 48 +++ .../Unit/Engines/MeilisearchEngineTest.php | 363 ++++++++++++++++++ 4 files changed, 563 insertions(+), 32 deletions(-) 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/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php index e67dbb8c5..3c24a2716 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,57 @@ 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(); + $this->assertNotNull($uid); + $this->assertNotNull($secret); + + try { + $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 { + $this->meilisearch->deleteKey($uid); + } + } + 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/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), [ From 0ad25bfdb157ecb91f5974bd459ef42fb59ca3f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:23:45 +0000 Subject: [PATCH 07/13] Add Typesense filter maintenance Compose raw Typesense filter_by expressions with Builder constraints while retaining their precedence, and pass final indexed documents and lazy-created schemas through Scout lifecycle preparation. Implement synchronous filtered deletion with prepared builders, explicit write-target selection, empty-filter refusal, and missing-collection handling. Cover search callbacks, schema authority, target selection, failure paths, and the real service behavior. --- src/scout/src/Engines/TypesenseEngine.php | 58 ++++- .../TypesenseFilteringIntegrationTest.php | 44 ++++ .../Unit/Engines/TypesenseEngineTest.php | 228 ++++++++++++++++++ 3 files changed, 327 insertions(+), 3 deletions(-) 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/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/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 From d57f58e313a557302ad53716c7cb9ccc489409de Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:23:53 +0000 Subject: [PATCH 08/13] Harden external search test cleanup Require Algolia index deletion waits to return a published terminal task so swallowed polling failures cannot let setup or teardown race unfinished cleanup. Limit Meilisearch's shared pending-task waiter to indexes owned by the current test prefix, preventing parallel workers from adopting each other's tasks. Add deterministic coverage for incomplete Algolia results and prefix-isolated Meilisearch waits. --- .../Testing/Concerns/InteractsWithAlgolia.php | 11 ++- .../Concerns/InteractsWithMeilisearch.php | 5 ++ .../Concerns/ExternalServiceOptInTest.php | 67 ++++++++++++++++++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php index 5ad866a8f..637502e5b 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; /** @@ -139,7 +141,14 @@ protected function cleanupAlgoliaIndices(): void } 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') { + throw new RuntimeException( + "Algolia index deletion task [{$task['taskID']}] for [{$task['indexName']}] did not complete." + ); + } } } } 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/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php index e83bfee72..329026959 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,6 +477,45 @@ 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 testAlgoliaCleanupPropagatesDeletionTaskFailures(): void { $failure = new RuntimeException('Index deletion failed.'); From 480d2c2661d6d363b50b66d1e359034970d3b599 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:24:08 +0000 Subject: [PATCH 09/13] Isolate the PHPStan types cache Give the standalone types analysis its own repository-local cache directory, matching the main PHPStan configuration's local-cache policy. This prevents cached paths from deleted worktrees from leaking into types-only analysis and keeps the two configurations from sharing incompatible result state. --- phpstan.types.neon.dist | 1 + 1 file changed, 1 insertion(+) 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 From 056b9d4d8a3e2949847703fa51c2b5e4152c1db7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:24:17 +0000 Subject: [PATCH 10/13] Document Scout lifecycle and filter maintenance Describe the lifecycle registration points, Builder preparation timing, document and settings callbacks, model-flush guards, and the optional filtered-deletion capability. Document raw-filter composition, completion guarantees, explicit Meilisearch token credentials, and forced command flushes. Keep the package README limited to concise, actionable differences from Laravel Scout. --- src/boost/docs/scout.md | 110 ++++++++++++++++++++++++++++++++++++++++ src/scout/README.md | 4 +- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/boost/docs/scout.md b/src/boost/docs/scout.md index 036388530..37e198993 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,31 @@ 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. + ### Pausing Indexing @@ -1021,6 +1117,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/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. From a48c815a958d8026a0174d35a389e126278524cd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:24:31 +0000 Subject: [PATCH 11/13] Record the Scout maintenance design Capture the completed lifecycle, filter-composition, filtered-deletion, explicit token-signing, and external-service verification contracts in a focused design record. Update the prior Scout and framework lifecycle plans to reflect truthful Algolia completion checks and per-worker Meilisearch task ownership. --- ...-coroutine-state-lifecycle-audit-ledger.md | 4 +- ...rent-parity-queue-and-search-lifecycles.md | 6 +- ...t-lifecycle-and-filter-maintenance-apis.md | 171 ++++++++++++++++++ 3 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-08-02-1749-scout-lifecycle-and-filter-maintenance-apis.md 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. From 64221b6cba5d7da7abefe595af18f26ef1784089 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:10:12 +0000 Subject: [PATCH 12/13] Harden Scout external cleanup completion Wait for every already-scheduled Algolia index deletion when the SDK returns an incomplete normal result, then report the first incomplete task. Preserve immediate propagation for SDK failures so retry exhaustion is not multiplied across indexes. Add two-index regressions for both completion boundaries, and ensure Meilisearch integration child keys are removed whenever either supported deletion identifier is available without masking nullable-response assertions. Document the asynchronous deletion behavior, Meilisearch wait bound, and recommended execution context for long filtered deletions. --- src/boost/docs/scout.md | 2 + .../Testing/Concerns/InteractsWithAlgolia.php | 10 +++- .../Concerns/ExternalServiceOptInTest.php | 47 ++++++++++++++++++- .../MeilisearchEngineIntegrationTest.php | 10 ++-- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/boost/docs/scout.md b/src/boost/docs/scout.md index 37e198993..6cf1d6474 100644 --- a/src/boost/docs/scout.md +++ b/src/boost/docs/scout.md @@ -954,6 +954,8 @@ $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 diff --git a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php index 637502e5b..2cbd08dbe 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithAlgolia.php @@ -120,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 { @@ -140,15 +142,21 @@ protected function cleanupAlgoliaIndices(): void } } + $failure = null; + foreach ($tasks as $task) { /** @var null|array|GetTaskResponse $result */ $result = $this->algolia->waitForTask($task['indexName'], $task['taskID']); if (($result['status'] ?? null) !== 'published') { - throw new RuntimeException( + $failure ??= new RuntimeException( "Algolia index deletion task [{$task['taskID']}] for [{$task['indexName']}] did not complete." ); } } + + if ($failure !== null) { + throw $failure; + } } } diff --git a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php index 329026959..b356abc21 100644 --- a/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php +++ b/tests/Foundation/Testing/Concerns/ExternalServiceOptInTest.php @@ -516,21 +516,66 @@ public static function incompleteAlgoliaDeletionTasks(): array ]; } + 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/Meilisearch/MeilisearchEngineIntegrationTest.php b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php index 3c24a2716..c6e6c266f 100644 --- a/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php +++ b/tests/Integration/Scout/Meilisearch/MeilisearchEngineIntegrationTest.php @@ -66,10 +66,12 @@ public function testTenantTokenUsesTheMatchingParentKeyIdentityAndSigner(): void ]); $uid = $key->getUid(); $secret = $key->getKey(); - $this->assertNotNull($uid); - $this->assertNotNull($secret); + $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); @@ -92,7 +94,9 @@ public function testTenantTokenUsesTheMatchingParentKeyIdentityAndSigner(): void $this->assertSame(403, $exception->httpStatus); } } finally { - $this->meilisearch->deleteKey($uid); + if ($cleanupIdentifier !== null) { + $this->meilisearch->deleteKey($cleanupIdentifier); + } } } From 75f453598d7fff78b7e0a63246adb9c04fd8f230 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:19:47 +0000 Subject: [PATCH 13/13] Await unscoped Meilisearch test index creation Capture and wait for the exact task returned when the scoped-deletion regression creates its deliberately unprefixed index. The shared task waiter correctly ignores indexes outside the current test prefix, so relying on it left the precondition vulnerable to Meilisearch asynchronous creation timing. Keep the prefix-scoped waiter unchanged for parallel isolation and make the real-service command regression deterministic on slower CI workers. --- .../Meilisearch/MeilisearchCommandsIntegrationTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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();