Skip to content

Commit

Permalink
Merge pull request #3082 from frknakk/master
Browse files Browse the repository at this point in the history
feat: Scout Search Implementation
  • Loading branch information
yajra committed Nov 4, 2023
2 parents b6aede4 + 5b0563f commit 82127aa
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 11 deletions.
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"illuminate/view": "^9|^10"
},
"require-dev": {
"algolia/algoliasearch-client-php": "^3.4",
"laravel/scout": "^10.5",
"meilisearch/meilisearch-php": "^1.4",
"nunomaduro/larastan": "^2.4",
"orchestra/testbench": "^8",
"yajra/laravel-datatables-html": "^9.3.4|^10"
Expand Down Expand Up @@ -60,7 +63,10 @@
}
},
"config": {
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
}
},
"scripts": {
"test": "vendor/bin/phpunit"
Expand Down
6 changes: 3 additions & 3 deletions src/CollectionDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -334,10 +334,10 @@ protected function getSorter(array $criteria): Closure
/**
* Resolve callback parameter instance.
*
* @return static
* @return array<int|string, mixed>
*/
protected function resolveCallbackParameter(): self
protected function resolveCallbackParameter(): array
{
return $this;
return [$this, false];
}
}
6 changes: 3 additions & 3 deletions src/DataTableAbstract.php
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ protected function isBlacklisted($column): bool
public function ordering(): void
{
if ($this->orderCallback) {
call_user_func($this->orderCallback, $this->resolveCallbackParameter());
call_user_func_array($this->orderCallback, $this->resolveCallbackParameter());
} else {
$this->defaultOrdering();
}
Expand All @@ -690,7 +690,7 @@ public function ordering(): void
/**
* Resolve callback parameter instance.
*
* @return mixed
* @return array<int|string, mixed>
*/
abstract protected function resolveCallbackParameter();

Expand Down Expand Up @@ -776,7 +776,7 @@ protected function filterRecords(): void
}

if (is_callable($this->filterCallback)) {
call_user_func($this->filterCallback, $this->resolveCallbackParameter());
call_user_func_array($this->filterCallback, $this->resolveCallbackParameter());
}

$this->columnSearch();
Expand Down
273 changes: 269 additions & 4 deletions src/QueryDataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Contracts\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Expression;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -56,6 +57,56 @@ class QueryDataTable extends DataTableAbstract
*/
protected bool $ignoreSelectInCountQuery = false;

/**
* Enable scout search and use this model for searching.
*
* @var Model|null
*/
protected ?Model $scoutModel = null;

/**
* Maximum number of hits to return from scout.
*
* @var int
*/
protected int $scoutMaxHits = 1000;

/**
* Add dynamic filters to scout search.
*
* @var callable|null
*/
protected $scoutFilterCallback = null;

/**
* Flag if scout search was performed.
*
* @var bool
*/
protected bool $scoutSearched = false;

/**
* Scout index name.
*
* @var string
*/
protected string $scoutIndex;

/**
* Scout key name.
*
* @var string
*/
protected string $scoutKey;

/**
* Flag to disable user ordering if a fixed ordering was performed (e.g. scout search).
* Only works with corresponding javascript listener.
*
* @var bool
*/
protected $disableUserOrdering = false;

/**
* @param QueryBuilder $builder
*/
Expand Down Expand Up @@ -237,7 +288,7 @@ protected function filterRecords(): void
}

if (is_callable($this->filterCallback)) {
call_user_func($this->filterCallback, $this->resolveCallbackParameter());
call_user_func_array($this->filterCallback, $this->resolveCallbackParameter());
}

$this->columnSearch();
Expand Down Expand Up @@ -673,11 +724,11 @@ protected function searchPanesSearch(): void
/**
* Resolve callback parameter instance.
*
* @return QueryBuilder
* @return array<int|string, mixed>
*/
protected function resolveCallbackParameter()
protected function resolveCallbackParameter(): array
{
return $this->query;
return [$this->query, $this->scoutSearched];
}

/**
Expand Down Expand Up @@ -778,6 +829,11 @@ protected function getNullsLastSql($column, $direction): string
*/
protected function globalSearch(string $keyword): void
{
// Try scout search first & fall back to default search if disabled/failed
if ($this->applyScoutSearch($keyword)) {
return;
}

$this->query->where(function ($query) use ($keyword) {
collect($this->request->searchableColumnIndex())
->map(function ($index) {
Expand Down Expand Up @@ -835,6 +891,9 @@ protected function attachAppends(array $data): array
}
}

// Set flag to disable ordering
$appends['disableOrdering'] = $this->disableUserOrdering;

return array_merge($data, $appends);
}

Expand All @@ -861,4 +920,210 @@ public function ignoreSelectsInCountQuery(): static

return $this;
}

/**
* Perform sorting of columns.
*
* @return void
*/
public function ordering(): void
{
// Skip if user ordering is disabled (e.g. scout search)
if ($this->disableUserOrdering) {
return;
}

parent::ordering();
}

/**
* Enable scout search and use provided model for searching.
* $max_hits is the maximum number of hits to return from scout.
*
* @param string $model
* @param int $max_hits
* @return $this
*/
public function enableScoutSearch(string $model, int $max_hits = 1000): static
{
$scout_model = new $model;
if (! class_exists($model) || ! ($scout_model instanceof Model)) {
throw new \Exception("$model must be an Eloquent Model.");
}
if (! method_exists($scout_model, 'searchableAs') || ! method_exists($scout_model, 'getScoutKeyName')) {
throw new \Exception("$model must use the Searchable trait.");
}

$this->scoutModel = $scout_model;
$this->scoutMaxHits = $max_hits;
$this->scoutIndex = $this->scoutModel->searchableAs();
$this->scoutKey = $this->scoutModel->getScoutKeyName();

return $this;
}

/**
* Add dynamic filters to scout search.
*
* @param callable $callback
* @return $this
*/
public function scoutFilter(callable $callback): static
{
$this->scoutFilterCallback = $callback;

return $this;
}

/**
* Apply scout search to query if enabled.
*
* @param string $search_keyword
* @return bool
*/
protected function applyScoutSearch(string $search_keyword): bool
{
if ($this->scoutModel == null) {
return false;
}

try {
// Perform scout search
$search_filters = '';
if (is_callable($this->scoutFilterCallback)) {
$search_filters = ($this->scoutFilterCallback)($search_keyword);
}

$search_results = $this->performScoutSearch($search_keyword, $search_filters);

// Apply scout search results to query
$this->query->where(function ($query) use ($search_results) {
$this->query->whereIn($this->scoutKey, $search_results);
});

// Order by scout search results & disable user ordering (if db driver is supported)
if (count($search_results) > 0 && $this->applyFixedOrderingToQuery($this->scoutKey, $search_results)) {
// Disable user ordering because we already ordered by search relevancy
$this->disableUserOrdering = true;
}

$this->scoutSearched = true;

return true;
} catch (\Exception) {
// Scout search failed, fallback to default search
return false;
}
}

/**
* Apply fixed ordering to query by a fixed set of values depending on database driver (used for scout search).
*
* Currently supported drivers: MySQL
*
* @param string $keyName
* @param array $orderedKeys
* @return bool
*/
protected function applyFixedOrderingToQuery(string $keyName, array $orderedKeys)
{
$connection = $this->getConnection();
$driver_name = $connection->getDriverName();

// Escape keyName and orderedKeys
$rawKeyName = $keyName;
$keyName = $connection->escape($keyName);
$orderedKeys = collect($orderedKeys)
->map(function ($value) use ($connection) {
return $connection->escape($value);
});

switch ($driver_name) {
case 'mysql':
// MySQL / MariaDB
$this->query->orderByRaw("FIELD($keyName, ".$orderedKeys->implode(',').')');
return true;

/*
TODO: test implementations, fix if necessary and uncomment
case 'pgsql':
// PostgreSQL
$this->query->orderByRaw("array_position(ARRAY[" . $orderedKeys->implode(',') . "], $keyName)");
return true;
*/

case 'sqlite':
case 'sqlsrv':
// SQLite & Microsoft SQL Server
// Compatible with all SQL drivers (but ugly solution)

$this->query->orderByRaw(
"CASE `$rawKeyName` "
.
$orderedKeys
->map(fn($value, $index) => "WHEN $value THEN $index")
->implode(' ')
.
" END"
);
return true;

default:
return false;
}
}

/**
* Perform a scout search with the configured engine and given parameters. Return matching model IDs.
*
* @param string $searchKeyword
* @param mixed $searchFilters
* @return array
*/
protected function performScoutSearch(string $searchKeyword, mixed $searchFilters = []): array
{
if (! class_exists('\Laravel\Scout\EngineManager')) {
throw new \Exception('Laravel Scout is not installed.');
}
$engine = app(\Laravel\Scout\EngineManager::class)->engine();

if ($engine instanceof \Laravel\Scout\Engines\MeilisearchEngine) {
/** @var \Meilisearch\Client $engine */
$search_results = $engine
->index($this->scoutIndex)
->rawSearch($searchKeyword, [
'limit' => $this->scoutMaxHits,
'attributesToRetrieve' => [$this->scoutKey],
'filter' => $searchFilters,
]);

/** @var array<int, array<string, mixed>> $hits */
$hits = $search_results['hits'] ?? [];

return collect($hits)
->pluck($this->scoutKey)
->all();
} elseif ($engine instanceof \Laravel\Scout\Engines\AlgoliaEngine) {
/** @var \Algolia\AlgoliaSearch\SearchClient $engine */
$algolia = $engine->initIndex($this->scoutIndex);

$search_results = $algolia->search($searchKeyword, [
'offset' => 0,
'length' => $this->scoutMaxHits,
'attributesToRetrieve' => [$this->scoutKey],
'attributesToHighlight' => [],
'filters' => $searchFilters,
]);

/** @var array<int, array<string, mixed>> $hits */
$hits = $search_results['hits'] ?? [];

return collect($hits)
->pluck($this->scoutKey)
->all();
} else {
throw new \Exception('Unsupported Scout Engine. Currently supported: Meilisearch, Algolia');
}
}
}

0 comments on commit 82127aa

Please sign in to comment.