Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
102 changes: 51 additions & 51 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
61 changes: 61 additions & 0 deletions src/CursorPaginator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace DirectoryTree\OpenSearchScoutDriver;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Pagination\Cursor;
use Illuminate\Pagination\CursorPaginator as BaseCursorPaginator;
use UnexpectedValueException;

class CursorPaginator extends BaseCursorPaginator
{
/**
* The cursor parameter containing OpenSearch hit sort values.
*/
public const SEARCH_AFTER_PARAMETER = '_search_after';

/**
* @param array<string, array<int, mixed>> $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]];
}
}
80 changes: 80 additions & 0 deletions src/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +41,8 @@ public function __construct(

/**
* Update the given models in the index.
*
* @param Collection $models
*/
public function update($models): void
{
Expand All @@ -54,6 +59,8 @@ public function update($models): void

/**
* Delete the given models from the index.
*
* @param Collection $models
*/
public function delete($models): void
{
Expand All @@ -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
{
Expand All @@ -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.
*/
Expand All @@ -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
{
Expand All @@ -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
{
Expand All @@ -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<string, array<int, mixed>>
*/
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
{
Expand All @@ -137,6 +213,8 @@ public function flush($model): void

/**
* Create an index.
*
* @param string $name
*/
public function createIndex($name, array $options = []): void
{
Expand All @@ -149,6 +227,8 @@ public function createIndex($name, array $options = []): void

/**
* Delete an index.
*
* @param string $name
*/
public function deleteIndex($name): void
{
Expand Down
12 changes: 6 additions & 6 deletions src/Factories/ModelFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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();

Expand Down
Loading
Loading