diff --git a/README.md b/README.md index d7321d3..e2bfcdd 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,21 @@ $posts = Post::search('laravel')->get(); The driver converts Scout builders into OpenSearch search requests and uses the configured OpenSearch client connection to index, delete, flush, and search models. +### Cursor Pagination + +For deep pagination, use `cursorPaginate` with an explicit, stable sort: + +```php +$posts = Post::search('laravel') + ->orderBy('published_at', 'desc') + ->orderBy('id', 'desc') + ->cursorPaginate(25); +``` + +Cursor pagination uses OpenSearch `search_after` values internally and returns Laravel's standard `CursorPaginator` response shape, including `next_cursor` and `prev_cursor`. + +OpenSearch only returns reusable `search_after` values for sorted searches, so cursor pagination requires at least one explicit sort. Prefer adding a unique indexed tie-breaker, such as an indexed model key, as the final sort. + ## Credits This package builds on a lot of the foundation and prior work from [Ivan Babenko](https://github.com/babenkoivan) and his Elasticsearch Laravel ecosystem packages. diff --git a/composer.json b/composer.json index a6aa14b..da37fc5 100644 --- a/composer.json +++ b/composer.json @@ -1,54 +1,54 @@ { - "name": "directorytree/opensearch-scout-driver", - "description": "OpenSearch driver for Laravel Scout", - "keywords": [ - "opensearch", - "scout", - "laravel", - "driver", - "php" - ], - "type": "library", - "license": "MIT", - "authors": [ - { - "name": "Steve Bauman", - "email": "steven_bauman@outlook.com" - } - ], - "autoload": { - "psr-4": { - "DirectoryTree\\OpenSearchScoutDriver\\": "src" - } - }, - "autoload-dev": { - "psr-4": { - "DirectoryTree\\OpenSearchScoutDriver\\Tests\\": "tests" - } - }, - "require": { - "php": "^8.2", - "directorytree/opensearch-adapter": "^1.0", - "directorytree/opensearch-client": "^1.0", - "laravel/scout": "^10.0|^11.0|^12.0|^13.0" - }, - "require-dev": { - "laravel/framework": "^11.0|^12.0|^13.0", - "laravel/pint": "^1.0", - "orchestra/testbench": "^9.0|^10.0|^11.0", - "pestphp/pest": "^3.0" - }, - "config": { - "allow-plugins": { - "php-http/discovery": true, - "pestphp/pest-plugin": true - } - }, - "extra": { - "laravel": { - "providers": [ - "DirectoryTree\\OpenSearchScoutDriver\\OpenSearchScoutServiceProvider" - ] - } + "name": "directorytree/opensearch-scout-driver", + "description": "OpenSearch driver for Laravel Scout", + "keywords": [ + "opensearch", + "scout", + "laravel", + "driver", + "php" + ], + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" } + ], + "autoload": { + "psr-4": { + "DirectoryTree\\OpenSearchScoutDriver\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "DirectoryTree\\OpenSearchScoutDriver\\Tests\\": "tests" + } + }, + "require": { + "php": "^8.2", + "directorytree/opensearch-adapter": "^1.0.4", + "directorytree/opensearch-client": "^1.0", + "laravel/scout": "^10.0|^11.0|^12.0|^13.0" + }, + "require-dev": { + "laravel/framework": "^11.0|^12.0|^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "pestphp/pest-plugin": true + } + }, + "extra": { + "laravel": { + "providers": [ + "DirectoryTree\\OpenSearchScoutDriver\\OpenSearchScoutServiceProvider" + ] + } + } } diff --git a/src/CursorPaginator.php b/src/CursorPaginator.php new file mode 100644 index 0000000..0648664 --- /dev/null +++ b/src/CursorPaginator.php @@ -0,0 +1,61 @@ +> $searchAfter + */ + protected array $searchAfter = []; + + /** + * Resolve the cursor from the current request or explicit value. + */ + public static function resolveCursor(Cursor|string|null $cursor, string $cursorName = 'cursor'): ?Cursor + { + if ($cursor instanceof Cursor) { + return $cursor; + } + + if (is_string($cursor)) { + return Cursor::fromEncoded($cursor); + } + + return BaseCursorPaginator::resolveCurrentCursor($cursorName); + } + + /** + * Get the cursor parameters for a given item. + */ + public function getParametersForItem(mixed $item): array + { + /** @var Model $item */ + $item = $item instanceof JsonResource ? $item->resource : $item; + + if (! $item instanceof Model) { + throw new UnexpectedValueException('OpenSearch cursor pagination only supports Eloquent models.'); + } + + if (! method_exists($item, 'getScoutKey')) { + throw new UnexpectedValueException('OpenSearch cursor pagination only supports Eloquent models that use the Laravel Scout Searchable trait.'); + } + + if (! array_key_exists($key = $item->getScoutKey(), $this->searchAfter)) { + throw new UnexpectedValueException(sprintf('Unable to resolve OpenSearch search_after values for model [%s] with scout key [%s].', $item::class, $key)); + } + + return [self::SEARCH_AFTER_PARAMETER => $this->searchAfter[$key]]; + } +} diff --git a/src/Engine.php b/src/Engine.php index 5915841..55eb607 100644 --- a/src/Engine.php +++ b/src/Engine.php @@ -10,8 +10,11 @@ use DirectoryTree\OpenSearchScoutDriver\Factories\DocumentFactoryInterface; use DirectoryTree\OpenSearchScoutDriver\Factories\ModelFactoryInterface; use DirectoryTree\OpenSearchScoutDriver\Factories\SearchRequestFactoryInterface; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Pagination\Cursor; +use Illuminate\Pagination\Paginator; use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\LazyCollection; use InvalidArgumentException; @@ -38,6 +41,8 @@ public function __construct( /** * Update the given models in the index. + * + * @param Collection $models */ public function update($models): void { @@ -54,6 +59,8 @@ public function update($models): void /** * Delete the given models from the index. + * + * @param Collection $models */ public function delete($models): void { @@ -80,6 +87,9 @@ public function search(Builder $builder): SearchResponse /** * Perform the given paginated search. + * + * @param int $perPage + * @param int $page */ public function paginate(Builder $builder, $perPage, $page): SearchResponse { @@ -91,6 +101,46 @@ public function paginate(Builder $builder, $perPage, $page): SearchResponse return $this->documentManager->search($searchRequest->indexName(), $searchRequest->request()); } + /** + * Cursor paginate the given search using OpenSearch search_after values. + * + * @param int|null $perPage + * @param string $cursorName + * @param Cursor|null $cursor + */ + public function cursorPaginate(Builder $builder, $perPage = null, $cursorName = 'cursor', $cursor = null): CursorPaginator + { + $perPage = (int) ($perPage ?: $builder->model->getPerPage()); + + $cursor = CursorPaginator::resolveCursor($cursor, $cursorName); + + $searchRequest = $this->searchRequestFactory->makeFromBuilder($builder, [ + 'perPage' => $perPage + 1, + 'reversed' => $cursor?->pointsToPreviousItems() ?? false, + 'searchAfter' => $cursor?->parameter(CursorPaginator::SEARCH_AFTER_PARAMETER), + ]); + + if (! $searchRequest->request()->hasSort()) { + throw new InvalidArgumentException('OpenSearch cursor pagination requires at least one explicit sort.'); + } + + $response = $builder->applyAfterRawSearchCallback( + $this->documentManager->search($searchRequest->indexName(), $searchRequest->request()) + ); + + return new CursorPaginator( + $this->map($builder, $response, $builder->model), + $perPage, + $cursor, + [ + 'cursorName' => $cursorName, + 'path' => Paginator::resolveCurrentPath(), + 'parameters' => [CursorPaginator::SEARCH_AFTER_PARAMETER], + 'searchAfter' => $this->searchAfterValuesByDocumentId($response), + ], + ); + } + /** * Get the primary keys from the search results. */ @@ -101,6 +151,9 @@ public function mapIds($results): BaseCollection /** * Map the search results to models. + * + * @param SearchResponse $results + * @param Model $model */ public function map(Builder $builder, $results, $model): EloquentCollection { @@ -109,6 +162,9 @@ public function map(Builder $builder, $results, $model): EloquentCollection /** * Lazily map the search results to models. + * + * @param SearchResponse $results + * @param Model $model */ public function lazyMap(Builder $builder, $results, $model): LazyCollection { @@ -117,14 +173,34 @@ public function lazyMap(Builder $builder, $results, $model): LazyCollection /** * Get the total count from the search results. + * + * @param SearchResponse $results */ public function getTotalCount($results): ?int { return $results->total(); } + /** + * Get hit sort values keyed by document ID. + * + * @return array> + */ + protected function searchAfterValuesByDocumentId(SearchResponse $response): array + { + $values = []; + + foreach ($response->hits() as $hit) { + $values[$hit->document()->id()] = $hit->sort(); + } + + return $values; + } + /** * Remove all model records from the index. + * + * @param Model $model */ public function flush($model): void { @@ -137,6 +213,8 @@ public function flush($model): void /** * Create an index. + * + * @param string $name */ public function createIndex($name, array $options = []): void { @@ -149,6 +227,8 @@ public function createIndex($name, array $options = []): void /** * Delete an index. + * + * @param string $name */ public function deleteIndex($name): void { diff --git a/src/Factories/ModelFactory.php b/src/Factories/ModelFactory.php index 368ea3b..30d595e 100644 --- a/src/Factories/ModelFactory.php +++ b/src/Factories/ModelFactory.php @@ -20,12 +20,12 @@ class ModelFactory implements ModelFactoryInterface */ public function makeFromSearchResponse(SearchResponse $searchResponse, Builder $builder): EloquentCollection { - if (! $searchResponse->total()) { + $documentIds = $this->pluckDocumentIds($searchResponse); + + if (empty($documentIds)) { return $builder->model->newCollection(); } - $documentIds = $this->pluckDocumentIds($searchResponse); - /** @var EloquentCollection $models */ $models = $builder->model->getScoutModelsByIds($builder, $documentIds); @@ -37,12 +37,12 @@ public function makeFromSearchResponse(SearchResponse $searchResponse, Builder $ */ public function makeLazyFromSearchResponse(SearchResponse $searchResponse, Builder $builder): LazyCollection { - if (! $searchResponse->total()) { + $documentIds = $this->pluckDocumentIds($searchResponse); + + if (empty($documentIds)) { return LazyCollection::make($builder->model->newCollection()); } - $documentIds = $this->pluckDocumentIds($searchResponse); - /** @var LazyCollection $models */ $models = $builder->model->queryScoutModelsByIds($builder, $documentIds)->cursor(); diff --git a/src/Factories/SearchRequestFactory.php b/src/Factories/SearchRequestFactory.php index cc8e620..3afbecc 100644 --- a/src/Factories/SearchRequestFactory.php +++ b/src/Factories/SearchRequestFactory.php @@ -16,7 +16,7 @@ class SearchRequestFactory implements SearchRequestFactoryInterface /** * Create an OpenSearch request from the given Scout builder. * - * @param array{page?: int, perPage?: int} $options + * @param array{page?: int, perPage?: int, searchAfter?: array, reversed?: bool} $options */ public function makeFromBuilder(Builder $builder, array $options = []): SearchRequest { @@ -24,17 +24,21 @@ public function makeFromBuilder(Builder $builder, array $options = []): SearchRe ? $builder->toSearchRequestPayload($options) : SearchRequestPayload::fromBuilder($builder, $options); - return $this->makeFromPayload($builder, $payload); + return $this->makeFromPayload($builder, $payload, $options); } /** * Make an OpenSearch request from a builder payload. */ - protected function makeFromPayload(Builder $builder, SearchRequestPayload $payload): SearchRequest + protected function makeFromPayload(Builder $builder, SearchRequestPayload $payload, array $options = []): SearchRequest { $request = new OpenSearchRequest($payload->query()); if ($sort = $payload->sort()) { + if ($options['reversed'] ?? false) { + $sort = $this->reverseSort($sort); + } + $request->sort($sort); } @@ -46,6 +50,10 @@ protected function makeFromPayload(Builder $builder, SearchRequestPayload $paylo $request->size($size); } + if ($searchAfter = $payload->searchAfter()) { + $request->searchAfter($searchAfter); + } + if ($aggregations = $payload->aggregations()) { $request->aggregations($aggregations); } @@ -118,6 +126,10 @@ protected function applyOptions(OpenSearchRequest $request, array $options): voi $request->source($options['_source']); } + if (array_key_exists('search_after', $options)) { + $request->searchAfter($options['search_after']); + } + if (array_key_exists('track_scores', $options)) { $request->trackScores($options['track_scores']); } @@ -126,4 +138,45 @@ protected function applyOptions(OpenSearchRequest $request, array $options): voi $request->trackTotalHits($options['track_total_hits']); } } + + /** + * Reverse the sort directions for previous cursor pagination. + * + * @param array $sort + * @return array + */ + protected function reverseSort(array $sort): array + { + return array_map(function (mixed $sort): mixed { + if (is_string($sort)) { + return [$sort => $sort === '_score' ? 'asc' : 'desc']; + } + + if (! is_array($sort)) { + return $sort; + } + + foreach ($sort as $field => $definition) { + if (is_string($definition)) { + $sort[$field] = $this->reverseSortDirection($definition); + + continue; + } + + if (is_array($definition)) { + $sort[$field]['order'] = $this->reverseSortDirection($definition['order'] ?? 'asc'); + } + } + + return $sort; + }, $sort); + } + + /** + * Reverse an OpenSearch sort direction. + */ + protected function reverseSortDirection(string $direction): string + { + return strtolower($direction) === 'asc' ? 'desc' : 'asc'; + } } diff --git a/src/OpenSearchScoutServiceProvider.php b/src/OpenSearchScoutServiceProvider.php index 341e513..87864f1 100644 --- a/src/OpenSearchScoutServiceProvider.php +++ b/src/OpenSearchScoutServiceProvider.php @@ -2,6 +2,7 @@ namespace DirectoryTree\OpenSearchScoutDriver; +use BadMethodCallException; use DirectoryTree\OpenSearchAdapter\Documents\DocumentManager; use DirectoryTree\OpenSearchAdapter\Documents\DocumentManagerInterface; use DirectoryTree\OpenSearchAdapter\Indices\IndexManager; @@ -15,6 +16,7 @@ use DirectoryTree\OpenSearchScoutDriver\Factories\SearchRequestFactoryInterface; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; +use Laravel\Scout\Builder; use Laravel\Scout\EngineManager; /** @@ -68,5 +70,28 @@ public function boot(): void $this->app->make(EngineManager::class)->extend('opensearch', function (Application $app) { return $app->make(Engine::class); }); + + $this->registerBuilderMacros(); + } + + /** + * Register OpenSearch Scout builder macros. + */ + protected function registerBuilderMacros(): void + { + if (Builder::hasMacro('cursorPaginate')) { + return; + } + + Builder::macro('cursorPaginate', function ($perPage = null, string $cursorName = 'cursor', $cursor = null) { + /** @var Builder $this */ + $engine = $this->engine(); + + if (! $engine instanceof Engine) { + throw new BadMethodCallException('Scout cursor pagination is only available for the OpenSearch engine.'); + } + + return $engine->cursorPaginate($this, $perPage, $cursorName, $cursor)->appends('query', $this->query); + }); } } diff --git a/src/SearchRequestPayload.php b/src/SearchRequestPayload.php index c1e56ae..620fabf 100644 --- a/src/SearchRequestPayload.php +++ b/src/SearchRequestPayload.php @@ -16,6 +16,7 @@ class SearchRequestPayload * @param array $query * @param array|null $sort * @param array|null $aggregations + * @param array|null $searchAfter */ public function __construct( protected array $query = [], @@ -23,12 +24,13 @@ public function __construct( protected ?int $size = null, protected ?array $sort = null, protected ?array $aggregations = null, + protected ?array $searchAfter = null, ) {} /** * Create a new search request payload from the given Scout builder. * - * @param array{page?: int, perPage?: int} $options + * @param array{page?: int, perPage?: int, searchAfter?: array} $options */ public static function fromBuilder(Builder $builder, array $options = []): self { @@ -37,6 +39,7 @@ public static function fromBuilder(Builder $builder, array $options = []): self from: static::makeFrom($options), size: static::makeSize($builder, $options), sort: static::makeSort($builder), + searchAfter: static::makeSearchAfter($options), ); } @@ -50,6 +53,14 @@ public function query(): array return $this->query; } + /** + * Get the maximum number of results. + */ + public function size(): ?int + { + return $this->size; + } + /** * Get the sort definitions. * @@ -69,11 +80,13 @@ public function from(): ?int } /** - * Get the maximum number of results. + * Get the hit sort values to search after. + * + * @return array|null */ - public function size(): ?int + public function searchAfter(): ?array { - return $this->size; + return $this->searchAfter; } /** @@ -98,6 +111,7 @@ public function toArray(): array 'sort' => $this->sort, 'from' => $this->from, 'size' => $this->size, + 'search_after' => $this->searchAfter, 'aggregations' => $this->aggregations, ], fn (mixed $value) => filled($value)); } @@ -179,10 +193,14 @@ protected static function makeSort(Builder $builder): ?array /** * Create the OpenSearch result offset. * - * @param array{page?: int, perPage?: int} $options + * @param array{page?: int, perPage?: int, searchAfter?: array} $options */ protected static function makeFrom(array $options): ?int { + if (isset($options['searchAfter'])) { + return null; + } + if (isset($options['page'], $options['perPage'])) { return ($options['page'] - 1) * $options['perPage']; } @@ -190,6 +208,17 @@ protected static function makeFrom(array $options): ?int return null; } + /** + * Create the OpenSearch search-after values. + * + * @param array{searchAfter?: array} $options + * @return array|null + */ + protected static function makeSearchAfter(array $options): ?array + { + return $options['searchAfter'] ?? null; + } + /** * Create the OpenSearch result size. * diff --git a/tests/Feature/OpenSearchScoutServiceProviderTest.php b/tests/Feature/OpenSearchScoutServiceProviderTest.php index 84dc167..3eb7d4b 100644 --- a/tests/Feature/OpenSearchScoutServiceProviderTest.php +++ b/tests/Feature/OpenSearchScoutServiceProviderTest.php @@ -2,10 +2,18 @@ use DirectoryTree\OpenSearchAdapter\Documents\DocumentManagerInterface; use DirectoryTree\OpenSearchAdapter\Indices\IndexManagerInterface; +use DirectoryTree\OpenSearchAdapter\Testing\Fakes\FakeDocumentManager; +use DirectoryTree\OpenSearchAdapter\Testing\Fakes\FakeIndexManager; +use DirectoryTree\OpenSearchScoutDriver\CursorPaginator; use DirectoryTree\OpenSearchScoutDriver\Engine; +use DirectoryTree\OpenSearchScoutDriver\Factories\DocumentFactory; use DirectoryTree\OpenSearchScoutDriver\Factories\DocumentFactoryInterface; +use DirectoryTree\OpenSearchScoutDriver\Factories\ModelFactory; use DirectoryTree\OpenSearchScoutDriver\Factories\ModelFactoryInterface; +use DirectoryTree\OpenSearchScoutDriver\Factories\SearchRequestFactory; use DirectoryTree\OpenSearchScoutDriver\Factories\SearchRequestFactoryInterface; +use DirectoryTree\OpenSearchScoutDriver\Tests\Fixtures\Client; +use Laravel\Scout\Builder; use Laravel\Scout\EngineManager; it('registers the opensearch scout engine', function () { @@ -19,3 +27,51 @@ ->and(app(DocumentManagerInterface::class))->toBeInstanceOf(DocumentManagerInterface::class) ->and(app(IndexManagerInterface::class))->toBeInstanceOf(IndexManagerInterface::class); }); + +it('registers the cursor paginate builder macro', function () { + expect(Builder::hasMacro('cursorPaginate'))->toBeTrue(); +}); + +it('appends the search query to cursor pagination urls', function () { + app()->bind(Engine::class, fn () => new class extends Engine + { + public function __construct() + { + parent::__construct( + new ModelFactory, + new FakeIndexManager, + new FakeDocumentManager, + new DocumentFactory, + new SearchRequestFactory, + true, + ); + } + + public function cursorPaginate(Builder $builder, $perPage = null, $cursorName = 'cursor', $cursor = null): CursorPaginator + { + return new CursorPaginator( + (new Client)->newCollection([ + new Client(['id' => 1]), + new Client(['id' => 2]), + ]), + 1, + null, + [ + 'cursorName' => $cursorName, + 'path' => '/clients', + 'parameters' => [CursorPaginator::SEARCH_AFTER_PARAMETER], + 'searchAfter' => [ + '1' => ['john@example.com', '1'], + '2' => ['jane@example.com', '2'], + ], + ], + ); + } + }); + + app(EngineManager::class)->forgetDrivers(); + + $paginator = Client::search('john')->cursorPaginate(1); + + expect($paginator->nextPageUrl())->toContain('query=john'); +}); diff --git a/tests/Integration/OpenSearchScoutDriverTest.php b/tests/Integration/OpenSearchScoutDriverTest.php index b1789ae..e558ce4 100644 --- a/tests/Integration/OpenSearchScoutDriverTest.php +++ b/tests/Integration/OpenSearchScoutDriverTest.php @@ -59,6 +59,18 @@ ->and(Client::search('')->whereIn('email', ['john@example.com', 'taylor@example.com'])->get()->pluck('id')->all())->toBe([1, 3]) ->and(Client::search('')->orderBy('name', 'asc')->paginate(2)->pluck('id')->all())->toBe([2, 1]); + $page = Client::search('') + ->orderBy('email') + ->cursorPaginate(2); + + $nextPage = Client::search('') + ->orderBy('email') + ->cursorPaginate(2, 'cursor', $page->nextCursor()); + + expect(collect($page->items())->pluck('id')->all())->toBe([2, 1]) + ->and($page->nextCursor())->not->toBeNull() + ->and(collect($nextPage->items())->pluck('id')->all())->toBe([3]); + $engine->delete(Client::query()->whereKey(1)->get()); expect(Client::search('John')->get())->toHaveCount(0); diff --git a/tests/Unit/EngineTest.php b/tests/Unit/EngineTest.php index b2d311e..b06dddd 100644 --- a/tests/Unit/EngineTest.php +++ b/tests/Unit/EngineTest.php @@ -1,15 +1,22 @@ assertSearched('clients', $request->request()); }); +it('cursor paginates models using search after sort values', function () { + $response = SearchResponse::fake([ + Hit::fake(['name' => 'John'], index: 'clients', id: '1', attributes: ['sort' => ['john@example.com', '1']]), + Hit::fake(['name' => 'Jane'], index: 'clients', id: '2', attributes: ['sort' => ['jane@example.com', '2']]), + Hit::fake(['name' => 'Taylor'], index: 'clients', id: '3', attributes: ['sort' => ['taylor@example.com', '3']]), + ], 'clients'); + + $documentManager = new FakeDocumentManager($response); + + $modelFactory = new class implements ModelFactoryInterface + { + public function makeFromSearchResponse(SearchResponse $searchResponse, Builder $builder): Collection + { + return $builder->model->newCollection([ + new Client(['id' => 1, 'name' => 'John']), + new Client(['id' => 2, 'name' => 'Jane']), + new Client(['id' => 3, 'name' => 'Taylor']), + ]); + } + + public function makeLazyFromSearchResponse(SearchResponse $searchResponse, Builder $builder): LazyCollection + { + return LazyCollection::make($this->makeFromSearchResponse($searchResponse, $builder)); + } + }; + + $engine = new Engine( + $modelFactory, + new FakeIndexManager, + $documentManager, + new DocumentFactory, + new SearchRequestFactory, + true, + ); + + $builder = Client::search('')->orderBy('email')->orderBy('id'); + + $paginator = $engine->cursorPaginate($builder, 2); + + $request = new OpenSearchRequest([ + 'bool' => [ + 'must' => [ + 'match_all' => new stdClass, + ], + ], + ]); + + $request->sort([ + ['email' => 'asc'], + ['id' => 'asc'], + ])->size(3); + + expect($paginator)->toBeInstanceOf(CursorPaginator::class) + ->and($paginator->items())->toHaveCount(2) + ->and($paginator->nextCursor()?->parameter(CursorPaginator::SEARCH_AFTER_PARAMETER))->toBe(['jane@example.com', '2']) + ->and($paginator->previousCursor())->toBeNull(); + + $documentManager->assertSearched('clients', $request); +}); + +it('cursor paginates previous pages by reversing sort directions', function () { + $response = SearchResponse::fake([ + Hit::fake(['name' => 'John'], index: 'clients', id: '1', attributes: ['sort' => ['john@example.com', '1']]), + Hit::fake(['name' => 'Jane'], index: 'clients', id: '2', attributes: ['sort' => ['jane@example.com', '2']]), + Hit::fake(['name' => 'Taylor'], index: 'clients', id: '3', attributes: ['sort' => ['taylor@example.com', '3']]), + ], 'clients'); + + $documentManager = new FakeDocumentManager($response); + + $modelFactory = new class implements ModelFactoryInterface + { + public function makeFromSearchResponse(SearchResponse $searchResponse, Builder $builder): Collection + { + return $builder->model->newCollection([ + new Client(['id' => 1, 'name' => 'John']), + new Client(['id' => 2, 'name' => 'Jane']), + new Client(['id' => 3, 'name' => 'Taylor']), + ]); + } + + public function makeLazyFromSearchResponse(SearchResponse $searchResponse, Builder $builder): LazyCollection + { + return LazyCollection::make($this->makeFromSearchResponse($searchResponse, $builder)); + } + }; + + $engine = new Engine( + $modelFactory, + new FakeIndexManager, + $documentManager, + new DocumentFactory, + new SearchRequestFactory, + true, + ); + + $cursor = new Cursor([ + CursorPaginator::SEARCH_AFTER_PARAMETER => ['jane@example.com', '2'], + ], false); + + $engine->cursorPaginate(Client::search('')->orderBy('email')->orderBy('id'), 2, cursor: $cursor); + + $request = new OpenSearchRequest([ + 'bool' => [ + 'must' => [ + 'match_all' => new stdClass, + ], + ], + ]); + + $request->sort([ + ['email' => 'desc'], + ['id' => 'desc'], + ])->size(3)->searchAfter(['jane@example.com', '2']); + + $documentManager->assertSearched('clients', $request); +}); + +it('applies raw result callbacks when cursor paginating', function () { + $response = SearchResponse::fake([ + Hit::fake(['name' => 'John'], index: 'clients', id: '1', attributes: ['sort' => ['john@example.com', '1']]), + Hit::fake(['name' => 'Jane'], index: 'clients', id: '2', attributes: ['sort' => ['jane@example.com', '2']]), + Hit::fake(['name' => 'Taylor'], index: 'clients', id: '3', attributes: ['sort' => ['taylor@example.com', '3']]), + ], 'clients'); + + $callbackResponse = SearchResponse::fake([ + Hit::fake(['name' => 'Adam'], index: 'clients', id: '4', attributes: ['sort' => ['adam@example.com', '4']]), + Hit::fake(['name' => 'Erin'], index: 'clients', id: '5', attributes: ['sort' => ['erin@example.com', '5']]), + Hit::fake(['name' => 'Zoe'], index: 'clients', id: '6', attributes: ['sort' => ['zoe@example.com', '6']]), + ], 'clients'); + + $documentManager = new FakeDocumentManager($response); + + $modelFactory = new class implements ModelFactoryInterface + { + public function makeFromSearchResponse(SearchResponse $searchResponse, Builder $builder): Collection + { + return $builder->model->newCollection(array_map( + fn (Hit $hit) => new Client(['id' => (int) $hit->document()->id()]), + $searchResponse->hits(), + )); + } + + public function makeLazyFromSearchResponse(SearchResponse $searchResponse, Builder $builder): LazyCollection + { + return LazyCollection::make($this->makeFromSearchResponse($searchResponse, $builder)); + } + }; + + $engine = new Engine( + $modelFactory, + new FakeIndexManager, + $documentManager, + new DocumentFactory, + new SearchRequestFactory, + true, + ); + + $builder = Client::search('') + ->orderBy('email') + ->orderBy('id') + ->withRawResults(function (SearchResponse $results) use ($callbackResponse) { + expect($results->hits()[0]->document()->id())->toBe('1'); + + return $callbackResponse; + }); + + $paginator = $engine->cursorPaginate($builder, 2); + + expect(collect($paginator->items())->pluck('id')->all())->toBe([4, 5]) + ->and($paginator->nextCursor()?->parameter(CursorPaginator::SEARCH_AFTER_PARAMETER))->toBe(['erin@example.com', '5']); +}); + +it('requires an explicit sort for cursor pagination', function () { + $engine = new Engine( + new ModelFactory, + new FakeIndexManager, + new FakeDocumentManager, + new DocumentFactory, + new SearchRequestFactory, + true, + ); + + $engine->cursorPaginate(Client::search(''), 2); +})->throws(InvalidArgumentException::class, 'OpenSearch cursor pagination requires at least one explicit sort.'); + it('flushes model indexes', function () { $documentManager = new FakeDocumentManager; diff --git a/tests/Unit/Factories/ModelFactoryTest.php b/tests/Unit/Factories/ModelFactoryTest.php index 8f856b0..1a09d64 100644 --- a/tests/Unit/Factories/ModelFactoryTest.php +++ b/tests/Unit/Factories/ModelFactoryTest.php @@ -1,5 +1,6 @@ makeFromSearchResponse($response, $builder))->toBeInstanceOf(Collection::class)->toBeEmpty(); }); + +it('hydrates hits when total hit metadata is not returned', function () { + $model = new class extends Client + { + public function getScoutModelsByIds($builder, array $ids) + { + return $this->newCollection(array_map( + fn (string $id) => new Client(['id' => (int) $id]), + $ids, + )); + } + }; + + $builder = new Builder($model, 'john'); + + $response = new SearchResponse([ + 'hits' => [ + 'hits' => [ + Hit::fake(['name' => 'John'], id: '1')->raw(), + ], + ], + ]); + + expect((new ModelFactory)->makeFromSearchResponse($response, $builder)) + ->toBeInstanceOf(Collection::class) + ->toHaveCount(1); +}); diff --git a/tests/Unit/Factories/SearchRequestFactoryTest.php b/tests/Unit/Factories/SearchRequestFactoryTest.php index 2d8b344..dbb54e2 100644 --- a/tests/Unit/Factories/SearchRequestFactoryTest.php +++ b/tests/Unit/Factories/SearchRequestFactoryTest.php @@ -100,6 +100,47 @@ ->and($request->toArray()['body']['size'])->toBe(30); }); +it('creates search requests with search after', function () { + $builder = new Builder(new Client, 'john'); + $builder->orderBy('email'); + $builder->orderBy('id'); + + $request = (new SearchRequestFactory)->makeFromBuilder($builder, [ + 'perPage' => 30, + 'searchAfter' => ['john@example.com', 1], + ]); + + expect($request->toArray()['body'])->toMatchArray([ + 'sort' => [ + ['email' => 'asc'], + ['id' => 'asc'], + ], + 'size' => 30, + 'search_after' => ['john@example.com', 1], + ])->not->toHaveKey('from'); +}); + +it('reverses search requests for previous cursor pagination', function () { + $builder = new Builder(new Client, 'john'); + $builder->orderBy('email'); + $builder->orderBy('name', 'desc'); + + $request = (new SearchRequestFactory)->makeFromBuilder($builder, [ + 'perPage' => 30, + 'searchAfter' => ['john@example.com', 'John'], + 'reversed' => true, + ]); + + expect($request->toArray()['body'])->toMatchArray([ + 'sort' => [ + ['email' => 'desc'], + ['name' => 'asc'], + ], + 'size' => 30, + 'search_after' => ['john@example.com', 'John'], + ]); +}); + it('creates search requests with opensearch body options', function () { $builder = (new Builder(new Client, 'john'))->options([ 'highlight' => ['fields' => ['name' => new stdClass]], @@ -110,6 +151,7 @@ 'aggregations' => ['emails' => ['terms' => ['field' => 'email']]], 'post_filter' => ['term' => ['active' => true]], 'track_total_hits' => true, + 'search_after' => ['john@example.com', 1], 'indices_boost' => [['clients' => 1.5]], 'track_scores' => false, 'min_score' => 1.25, @@ -126,6 +168,7 @@ ->and($request['body']['aggregations'])->toBe(['emails' => ['terms' => ['field' => 'email']]]) ->and($request['body']['post_filter'])->toBe(['term' => ['active' => true]]) ->and($request['body']['track_total_hits'])->toBeTrue() + ->and($request['body']['search_after'])->toBe(['john@example.com', 1]) ->and($request['body']['indices_boost'])->toBe([['clients' => 1.5]]) ->and($request['body']['track_scores'])->toBeFalse() ->and($request['body']['min_score'])->toBe(1.25) diff --git a/tests/Unit/SearchRequestPayloadTest.php b/tests/Unit/SearchRequestPayloadTest.php index 1000b1a..4b280b1 100644 --- a/tests/Unit/SearchRequestPayloadTest.php +++ b/tests/Unit/SearchRequestPayloadTest.php @@ -36,6 +36,33 @@ ]); }); +it('can be created with search after values', function () { + $builder = (new Builder(new Client, 'john')) + ->orderBy('email') + ->orderBy('id'); + + $payload = SearchRequestPayload::fromBuilder($builder, [ + 'perPage' => 15, + 'searchAfter' => ['john@example.com', 1], + ]); + + expect($payload->toArray())->toEqual([ + 'query' => [ + 'bool' => [ + 'must' => [ + 'query_string' => ['query' => 'john'], + ], + ], + ], + 'sort' => [ + ['email' => 'asc'], + ['id' => 'asc'], + ], + 'size' => 15, + 'search_after' => ['john@example.com', 1], + ]); +}); + it('filters empty payload values when cast to an array', function () { $payload = new SearchRequestPayload( aggregations: [