Skip to content

Commit 14f0ba1

Browse files
authored
Merge pull request #1 from DirectoryTree/cursor-pagination
Cursor pagination using `search_after`
2 parents 235dde1 + 2a5845e commit 14f0ba1

14 files changed

Lines changed: 686 additions & 65 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ $posts = Post::search('laravel')->get();
7777

7878
The driver converts Scout builders into OpenSearch search requests and uses the configured OpenSearch client connection to index, delete, flush, and search models.
7979

80+
### Cursor Pagination
81+
82+
For deep pagination, use `cursorPaginate` with an explicit, stable sort:
83+
84+
```php
85+
$posts = Post::search('laravel')
86+
->orderBy('published_at', 'desc')
87+
->orderBy('id', 'desc')
88+
->cursorPaginate(25);
89+
```
90+
91+
Cursor pagination uses OpenSearch `search_after` values internally and returns Laravel's standard `CursorPaginator` response shape, including `next_cursor` and `prev_cursor`.
92+
93+
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.
94+
8095
## Credits
8196

8297
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.

composer.json

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,54 @@
11
{
2-
"name": "directorytree/opensearch-scout-driver",
3-
"description": "OpenSearch driver for Laravel Scout",
4-
"keywords": [
5-
"opensearch",
6-
"scout",
7-
"laravel",
8-
"driver",
9-
"php"
10-
],
11-
"type": "library",
12-
"license": "MIT",
13-
"authors": [
14-
{
15-
"name": "Steve Bauman",
16-
"email": "steven_bauman@outlook.com"
17-
}
18-
],
19-
"autoload": {
20-
"psr-4": {
21-
"DirectoryTree\\OpenSearchScoutDriver\\": "src"
22-
}
23-
},
24-
"autoload-dev": {
25-
"psr-4": {
26-
"DirectoryTree\\OpenSearchScoutDriver\\Tests\\": "tests"
27-
}
28-
},
29-
"require": {
30-
"php": "^8.2",
31-
"directorytree/opensearch-adapter": "^1.0",
32-
"directorytree/opensearch-client": "^1.0",
33-
"laravel/scout": "^10.0|^11.0|^12.0|^13.0"
34-
},
35-
"require-dev": {
36-
"laravel/framework": "^11.0|^12.0|^13.0",
37-
"laravel/pint": "^1.0",
38-
"orchestra/testbench": "^9.0|^10.0|^11.0",
39-
"pestphp/pest": "^3.0"
40-
},
41-
"config": {
42-
"allow-plugins": {
43-
"php-http/discovery": true,
44-
"pestphp/pest-plugin": true
45-
}
46-
},
47-
"extra": {
48-
"laravel": {
49-
"providers": [
50-
"DirectoryTree\\OpenSearchScoutDriver\\OpenSearchScoutServiceProvider"
51-
]
52-
}
2+
"name": "directorytree/opensearch-scout-driver",
3+
"description": "OpenSearch driver for Laravel Scout",
4+
"keywords": [
5+
"opensearch",
6+
"scout",
7+
"laravel",
8+
"driver",
9+
"php"
10+
],
11+
"type": "library",
12+
"license": "MIT",
13+
"authors": [
14+
{
15+
"name": "Steve Bauman",
16+
"email": "steven_bauman@outlook.com"
5317
}
18+
],
19+
"autoload": {
20+
"psr-4": {
21+
"DirectoryTree\\OpenSearchScoutDriver\\": "src"
22+
}
23+
},
24+
"autoload-dev": {
25+
"psr-4": {
26+
"DirectoryTree\\OpenSearchScoutDriver\\Tests\\": "tests"
27+
}
28+
},
29+
"require": {
30+
"php": "^8.2",
31+
"directorytree/opensearch-adapter": "^1.0.4",
32+
"directorytree/opensearch-client": "^1.0",
33+
"laravel/scout": "^10.0|^11.0|^12.0|^13.0"
34+
},
35+
"require-dev": {
36+
"laravel/framework": "^11.0|^12.0|^13.0",
37+
"laravel/pint": "^1.0",
38+
"orchestra/testbench": "^9.0|^10.0|^11.0",
39+
"pestphp/pest": "^3.0"
40+
},
41+
"config": {
42+
"allow-plugins": {
43+
"php-http/discovery": true,
44+
"pestphp/pest-plugin": true
45+
}
46+
},
47+
"extra": {
48+
"laravel": {
49+
"providers": [
50+
"DirectoryTree\\OpenSearchScoutDriver\\OpenSearchScoutServiceProvider"
51+
]
52+
}
53+
}
5454
}

src/CursorPaginator.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace DirectoryTree\OpenSearchScoutDriver;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Http\Resources\Json\JsonResource;
7+
use Illuminate\Pagination\Cursor;
8+
use Illuminate\Pagination\CursorPaginator as BaseCursorPaginator;
9+
use UnexpectedValueException;
10+
11+
class CursorPaginator extends BaseCursorPaginator
12+
{
13+
/**
14+
* The cursor parameter containing OpenSearch hit sort values.
15+
*/
16+
public const SEARCH_AFTER_PARAMETER = '_search_after';
17+
18+
/**
19+
* @param array<string, array<int, mixed>> $searchAfter
20+
*/
21+
protected array $searchAfter = [];
22+
23+
/**
24+
* Resolve the cursor from the current request or explicit value.
25+
*/
26+
public static function resolveCursor(Cursor|string|null $cursor, string $cursorName = 'cursor'): ?Cursor
27+
{
28+
if ($cursor instanceof Cursor) {
29+
return $cursor;
30+
}
31+
32+
if (is_string($cursor)) {
33+
return Cursor::fromEncoded($cursor);
34+
}
35+
36+
return BaseCursorPaginator::resolveCurrentCursor($cursorName);
37+
}
38+
39+
/**
40+
* Get the cursor parameters for a given item.
41+
*/
42+
public function getParametersForItem(mixed $item): array
43+
{
44+
/** @var Model $item */
45+
$item = $item instanceof JsonResource ? $item->resource : $item;
46+
47+
if (! $item instanceof Model) {
48+
throw new UnexpectedValueException('OpenSearch cursor pagination only supports Eloquent models.');
49+
}
50+
51+
if (! method_exists($item, 'getScoutKey')) {
52+
throw new UnexpectedValueException('OpenSearch cursor pagination only supports Eloquent models that use the Laravel Scout Searchable trait.');
53+
}
54+
55+
if (! array_key_exists($key = $item->getScoutKey(), $this->searchAfter)) {
56+
throw new UnexpectedValueException(sprintf('Unable to resolve OpenSearch search_after values for model [%s] with scout key [%s].', $item::class, $key));
57+
}
58+
59+
return [self::SEARCH_AFTER_PARAMETER => $this->searchAfter[$key]];
60+
}
61+
}

src/Engine.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
use DirectoryTree\OpenSearchScoutDriver\Factories\DocumentFactoryInterface;
1111
use DirectoryTree\OpenSearchScoutDriver\Factories\ModelFactoryInterface;
1212
use DirectoryTree\OpenSearchScoutDriver\Factories\SearchRequestFactoryInterface;
13+
use Illuminate\Database\Eloquent\Collection;
1314
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
1415
use Illuminate\Database\Eloquent\Model;
16+
use Illuminate\Pagination\Cursor;
17+
use Illuminate\Pagination\Paginator;
1518
use Illuminate\Support\Collection as BaseCollection;
1619
use Illuminate\Support\LazyCollection;
1720
use InvalidArgumentException;
@@ -38,6 +41,8 @@ public function __construct(
3841

3942
/**
4043
* Update the given models in the index.
44+
*
45+
* @param Collection $models
4146
*/
4247
public function update($models): void
4348
{
@@ -54,6 +59,8 @@ public function update($models): void
5459

5560
/**
5661
* Delete the given models from the index.
62+
*
63+
* @param Collection $models
5764
*/
5865
public function delete($models): void
5966
{
@@ -80,6 +87,9 @@ public function search(Builder $builder): SearchResponse
8087

8188
/**
8289
* Perform the given paginated search.
90+
*
91+
* @param int $perPage
92+
* @param int $page
8393
*/
8494
public function paginate(Builder $builder, $perPage, $page): SearchResponse
8595
{
@@ -91,6 +101,46 @@ public function paginate(Builder $builder, $perPage, $page): SearchResponse
91101
return $this->documentManager->search($searchRequest->indexName(), $searchRequest->request());
92102
}
93103

104+
/**
105+
* Cursor paginate the given search using OpenSearch search_after values.
106+
*
107+
* @param int|null $perPage
108+
* @param string $cursorName
109+
* @param Cursor|null $cursor
110+
*/
111+
public function cursorPaginate(Builder $builder, $perPage = null, $cursorName = 'cursor', $cursor = null): CursorPaginator
112+
{
113+
$perPage = (int) ($perPage ?: $builder->model->getPerPage());
114+
115+
$cursor = CursorPaginator::resolveCursor($cursor, $cursorName);
116+
117+
$searchRequest = $this->searchRequestFactory->makeFromBuilder($builder, [
118+
'perPage' => $perPage + 1,
119+
'reversed' => $cursor?->pointsToPreviousItems() ?? false,
120+
'searchAfter' => $cursor?->parameter(CursorPaginator::SEARCH_AFTER_PARAMETER),
121+
]);
122+
123+
if (! $searchRequest->request()->hasSort()) {
124+
throw new InvalidArgumentException('OpenSearch cursor pagination requires at least one explicit sort.');
125+
}
126+
127+
$response = $builder->applyAfterRawSearchCallback(
128+
$this->documentManager->search($searchRequest->indexName(), $searchRequest->request())
129+
);
130+
131+
return new CursorPaginator(
132+
$this->map($builder, $response, $builder->model),
133+
$perPage,
134+
$cursor,
135+
[
136+
'cursorName' => $cursorName,
137+
'path' => Paginator::resolveCurrentPath(),
138+
'parameters' => [CursorPaginator::SEARCH_AFTER_PARAMETER],
139+
'searchAfter' => $this->searchAfterValuesByDocumentId($response),
140+
],
141+
);
142+
}
143+
94144
/**
95145
* Get the primary keys from the search results.
96146
*/
@@ -101,6 +151,9 @@ public function mapIds($results): BaseCollection
101151

102152
/**
103153
* Map the search results to models.
154+
*
155+
* @param SearchResponse $results
156+
* @param Model $model
104157
*/
105158
public function map(Builder $builder, $results, $model): EloquentCollection
106159
{
@@ -109,6 +162,9 @@ public function map(Builder $builder, $results, $model): EloquentCollection
109162

110163
/**
111164
* Lazily map the search results to models.
165+
*
166+
* @param SearchResponse $results
167+
* @param Model $model
112168
*/
113169
public function lazyMap(Builder $builder, $results, $model): LazyCollection
114170
{
@@ -117,14 +173,34 @@ public function lazyMap(Builder $builder, $results, $model): LazyCollection
117173

118174
/**
119175
* Get the total count from the search results.
176+
*
177+
* @param SearchResponse $results
120178
*/
121179
public function getTotalCount($results): ?int
122180
{
123181
return $results->total();
124182
}
125183

184+
/**
185+
* Get hit sort values keyed by document ID.
186+
*
187+
* @return array<string, array<int, mixed>>
188+
*/
189+
protected function searchAfterValuesByDocumentId(SearchResponse $response): array
190+
{
191+
$values = [];
192+
193+
foreach ($response->hits() as $hit) {
194+
$values[$hit->document()->id()] = $hit->sort();
195+
}
196+
197+
return $values;
198+
}
199+
126200
/**
127201
* Remove all model records from the index.
202+
*
203+
* @param Model $model
128204
*/
129205
public function flush($model): void
130206
{
@@ -137,6 +213,8 @@ public function flush($model): void
137213

138214
/**
139215
* Create an index.
216+
*
217+
* @param string $name
140218
*/
141219
public function createIndex($name, array $options = []): void
142220
{
@@ -149,6 +227,8 @@ public function createIndex($name, array $options = []): void
149227

150228
/**
151229
* Delete an index.
230+
*
231+
* @param string $name
152232
*/
153233
public function deleteIndex($name): void
154234
{

src/Factories/ModelFactory.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ class ModelFactory implements ModelFactoryInterface
2020
*/
2121
public function makeFromSearchResponse(SearchResponse $searchResponse, Builder $builder): EloquentCollection
2222
{
23-
if (! $searchResponse->total()) {
23+
$documentIds = $this->pluckDocumentIds($searchResponse);
24+
25+
if (empty($documentIds)) {
2426
return $builder->model->newCollection();
2527
}
2628

27-
$documentIds = $this->pluckDocumentIds($searchResponse);
28-
2929
/** @var EloquentCollection $models */
3030
$models = $builder->model->getScoutModelsByIds($builder, $documentIds);
3131

@@ -37,12 +37,12 @@ public function makeFromSearchResponse(SearchResponse $searchResponse, Builder $
3737
*/
3838
public function makeLazyFromSearchResponse(SearchResponse $searchResponse, Builder $builder): LazyCollection
3939
{
40-
if (! $searchResponse->total()) {
40+
$documentIds = $this->pluckDocumentIds($searchResponse);
41+
42+
if (empty($documentIds)) {
4143
return LazyCollection::make($builder->model->newCollection());
4244
}
4345

44-
$documentIds = $this->pluckDocumentIds($searchResponse);
45-
4646
/** @var LazyCollection $models */
4747
$models = $builder->model->queryScoutModelsByIds($builder, $documentIds)->cursor();
4848

0 commit comments

Comments
 (0)