Skip to content

Commit 82127aa

Browse files
authored
Merge pull request #3082 from frknakk/master
feat: Scout Search Implementation
2 parents b6aede4 + 5b0563f commit 82127aa

File tree

4 files changed

+282
-11
lines changed

4 files changed

+282
-11
lines changed

composer.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"illuminate/view": "^9|^10"
2323
},
2424
"require-dev": {
25+
"algolia/algoliasearch-client-php": "^3.4",
26+
"laravel/scout": "^10.5",
27+
"meilisearch/meilisearch-php": "^1.4",
2528
"nunomaduro/larastan": "^2.4",
2629
"orchestra/testbench": "^8",
2730
"yajra/laravel-datatables-html": "^9.3.4|^10"
@@ -60,7 +63,10 @@
6063
}
6164
},
6265
"config": {
63-
"sort-packages": true
66+
"sort-packages": true,
67+
"allow-plugins": {
68+
"php-http/discovery": true
69+
}
6470
},
6571
"scripts": {
6672
"test": "vendor/bin/phpunit"

src/CollectionDataTable.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,10 @@ protected function getSorter(array $criteria): Closure
334334
/**
335335
* Resolve callback parameter instance.
336336
*
337-
* @return static
337+
* @return array<int|string, mixed>
338338
*/
339-
protected function resolveCallbackParameter(): self
339+
protected function resolveCallbackParameter(): array
340340
{
341-
return $this;
341+
return [$this, false];
342342
}
343343
}

src/DataTableAbstract.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ protected function isBlacklisted($column): bool
681681
public function ordering(): void
682682
{
683683
if ($this->orderCallback) {
684-
call_user_func($this->orderCallback, $this->resolveCallbackParameter());
684+
call_user_func_array($this->orderCallback, $this->resolveCallbackParameter());
685685
} else {
686686
$this->defaultOrdering();
687687
}
@@ -690,7 +690,7 @@ public function ordering(): void
690690
/**
691691
* Resolve callback parameter instance.
692692
*
693-
* @return mixed
693+
* @return array<int|string, mixed>
694694
*/
695695
abstract protected function resolveCallbackParameter();
696696

@@ -776,7 +776,7 @@ protected function filterRecords(): void
776776
}
777777

778778
if (is_callable($this->filterCallback)) {
779-
call_user_func($this->filterCallback, $this->resolveCallbackParameter());
779+
call_user_func_array($this->filterCallback, $this->resolveCallbackParameter());
780780
}
781781

782782
$this->columnSearch();

src/QueryDataTable.php

Lines changed: 269 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder;
66
use Illuminate\Contracts\Database\Query\Builder as QueryBuilder;
77
use Illuminate\Database\Connection;
8+
use Illuminate\Database\Eloquent\Model;
89
use Illuminate\Database\Query\Expression;
910
use Illuminate\Http\JsonResponse;
1011
use Illuminate\Support\Collection;
@@ -56,6 +57,56 @@ class QueryDataTable extends DataTableAbstract
5657
*/
5758
protected bool $ignoreSelectInCountQuery = false;
5859

60+
/**
61+
* Enable scout search and use this model for searching.
62+
*
63+
* @var Model|null
64+
*/
65+
protected ?Model $scoutModel = null;
66+
67+
/**
68+
* Maximum number of hits to return from scout.
69+
*
70+
* @var int
71+
*/
72+
protected int $scoutMaxHits = 1000;
73+
74+
/**
75+
* Add dynamic filters to scout search.
76+
*
77+
* @var callable|null
78+
*/
79+
protected $scoutFilterCallback = null;
80+
81+
/**
82+
* Flag if scout search was performed.
83+
*
84+
* @var bool
85+
*/
86+
protected bool $scoutSearched = false;
87+
88+
/**
89+
* Scout index name.
90+
*
91+
* @var string
92+
*/
93+
protected string $scoutIndex;
94+
95+
/**
96+
* Scout key name.
97+
*
98+
* @var string
99+
*/
100+
protected string $scoutKey;
101+
102+
/**
103+
* Flag to disable user ordering if a fixed ordering was performed (e.g. scout search).
104+
* Only works with corresponding javascript listener.
105+
*
106+
* @var bool
107+
*/
108+
protected $disableUserOrdering = false;
109+
59110
/**
60111
* @param QueryBuilder $builder
61112
*/
@@ -237,7 +288,7 @@ protected function filterRecords(): void
237288
}
238289

239290
if (is_callable($this->filterCallback)) {
240-
call_user_func($this->filterCallback, $this->resolveCallbackParameter());
291+
call_user_func_array($this->filterCallback, $this->resolveCallbackParameter());
241292
}
242293

243294
$this->columnSearch();
@@ -673,11 +724,11 @@ protected function searchPanesSearch(): void
673724
/**
674725
* Resolve callback parameter instance.
675726
*
676-
* @return QueryBuilder
727+
* @return array<int|string, mixed>
677728
*/
678-
protected function resolveCallbackParameter()
729+
protected function resolveCallbackParameter(): array
679730
{
680-
return $this->query;
731+
return [$this->query, $this->scoutSearched];
681732
}
682733

683734
/**
@@ -778,6 +829,11 @@ protected function getNullsLastSql($column, $direction): string
778829
*/
779830
protected function globalSearch(string $keyword): void
780831
{
832+
// Try scout search first & fall back to default search if disabled/failed
833+
if ($this->applyScoutSearch($keyword)) {
834+
return;
835+
}
836+
781837
$this->query->where(function ($query) use ($keyword) {
782838
collect($this->request->searchableColumnIndex())
783839
->map(function ($index) {
@@ -835,6 +891,9 @@ protected function attachAppends(array $data): array
835891
}
836892
}
837893

894+
// Set flag to disable ordering
895+
$appends['disableOrdering'] = $this->disableUserOrdering;
896+
838897
return array_merge($data, $appends);
839898
}
840899

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

862921
return $this;
863922
}
923+
924+
/**
925+
* Perform sorting of columns.
926+
*
927+
* @return void
928+
*/
929+
public function ordering(): void
930+
{
931+
// Skip if user ordering is disabled (e.g. scout search)
932+
if ($this->disableUserOrdering) {
933+
return;
934+
}
935+
936+
parent::ordering();
937+
}
938+
939+
/**
940+
* Enable scout search and use provided model for searching.
941+
* $max_hits is the maximum number of hits to return from scout.
942+
*
943+
* @param string $model
944+
* @param int $max_hits
945+
* @return $this
946+
*/
947+
public function enableScoutSearch(string $model, int $max_hits = 1000): static
948+
{
949+
$scout_model = new $model;
950+
if (! class_exists($model) || ! ($scout_model instanceof Model)) {
951+
throw new \Exception("$model must be an Eloquent Model.");
952+
}
953+
if (! method_exists($scout_model, 'searchableAs') || ! method_exists($scout_model, 'getScoutKeyName')) {
954+
throw new \Exception("$model must use the Searchable trait.");
955+
}
956+
957+
$this->scoutModel = $scout_model;
958+
$this->scoutMaxHits = $max_hits;
959+
$this->scoutIndex = $this->scoutModel->searchableAs();
960+
$this->scoutKey = $this->scoutModel->getScoutKeyName();
961+
962+
return $this;
963+
}
964+
965+
/**
966+
* Add dynamic filters to scout search.
967+
*
968+
* @param callable $callback
969+
* @return $this
970+
*/
971+
public function scoutFilter(callable $callback): static
972+
{
973+
$this->scoutFilterCallback = $callback;
974+
975+
return $this;
976+
}
977+
978+
/**
979+
* Apply scout search to query if enabled.
980+
*
981+
* @param string $search_keyword
982+
* @return bool
983+
*/
984+
protected function applyScoutSearch(string $search_keyword): bool
985+
{
986+
if ($this->scoutModel == null) {
987+
return false;
988+
}
989+
990+
try {
991+
// Perform scout search
992+
$search_filters = '';
993+
if (is_callable($this->scoutFilterCallback)) {
994+
$search_filters = ($this->scoutFilterCallback)($search_keyword);
995+
}
996+
997+
$search_results = $this->performScoutSearch($search_keyword, $search_filters);
998+
999+
// Apply scout search results to query
1000+
$this->query->where(function ($query) use ($search_results) {
1001+
$this->query->whereIn($this->scoutKey, $search_results);
1002+
});
1003+
1004+
// Order by scout search results & disable user ordering (if db driver is supported)
1005+
if (count($search_results) > 0 && $this->applyFixedOrderingToQuery($this->scoutKey, $search_results)) {
1006+
// Disable user ordering because we already ordered by search relevancy
1007+
$this->disableUserOrdering = true;
1008+
}
1009+
1010+
$this->scoutSearched = true;
1011+
1012+
return true;
1013+
} catch (\Exception) {
1014+
// Scout search failed, fallback to default search
1015+
return false;
1016+
}
1017+
}
1018+
1019+
/**
1020+
* Apply fixed ordering to query by a fixed set of values depending on database driver (used for scout search).
1021+
*
1022+
* Currently supported drivers: MySQL
1023+
*
1024+
* @param string $keyName
1025+
* @param array $orderedKeys
1026+
* @return bool
1027+
*/
1028+
protected function applyFixedOrderingToQuery(string $keyName, array $orderedKeys)
1029+
{
1030+
$connection = $this->getConnection();
1031+
$driver_name = $connection->getDriverName();
1032+
1033+
// Escape keyName and orderedKeys
1034+
$rawKeyName = $keyName;
1035+
$keyName = $connection->escape($keyName);
1036+
$orderedKeys = collect($orderedKeys)
1037+
->map(function ($value) use ($connection) {
1038+
return $connection->escape($value);
1039+
});
1040+
1041+
switch ($driver_name) {
1042+
case 'mysql':
1043+
// MySQL / MariaDB
1044+
$this->query->orderByRaw("FIELD($keyName, ".$orderedKeys->implode(',').')');
1045+
return true;
1046+
1047+
/*
1048+
TODO: test implementations, fix if necessary and uncomment
1049+
case 'pgsql':
1050+
// PostgreSQL
1051+
$this->query->orderByRaw("array_position(ARRAY[" . $orderedKeys->implode(',') . "], $keyName)");
1052+
return true;
1053+
1054+
*/
1055+
1056+
case 'sqlite':
1057+
case 'sqlsrv':
1058+
// SQLite & Microsoft SQL Server
1059+
// Compatible with all SQL drivers (but ugly solution)
1060+
1061+
$this->query->orderByRaw(
1062+
"CASE `$rawKeyName` "
1063+
.
1064+
$orderedKeys
1065+
->map(fn($value, $index) => "WHEN $value THEN $index")
1066+
->implode(' ')
1067+
.
1068+
" END"
1069+
);
1070+
return true;
1071+
1072+
default:
1073+
return false;
1074+
}
1075+
}
1076+
1077+
/**
1078+
* Perform a scout search with the configured engine and given parameters. Return matching model IDs.
1079+
*
1080+
* @param string $searchKeyword
1081+
* @param mixed $searchFilters
1082+
* @return array
1083+
*/
1084+
protected function performScoutSearch(string $searchKeyword, mixed $searchFilters = []): array
1085+
{
1086+
if (! class_exists('\Laravel\Scout\EngineManager')) {
1087+
throw new \Exception('Laravel Scout is not installed.');
1088+
}
1089+
$engine = app(\Laravel\Scout\EngineManager::class)->engine();
1090+
1091+
if ($engine instanceof \Laravel\Scout\Engines\MeilisearchEngine) {
1092+
/** @var \Meilisearch\Client $engine */
1093+
$search_results = $engine
1094+
->index($this->scoutIndex)
1095+
->rawSearch($searchKeyword, [
1096+
'limit' => $this->scoutMaxHits,
1097+
'attributesToRetrieve' => [$this->scoutKey],
1098+
'filter' => $searchFilters,
1099+
]);
1100+
1101+
/** @var array<int, array<string, mixed>> $hits */
1102+
$hits = $search_results['hits'] ?? [];
1103+
1104+
return collect($hits)
1105+
->pluck($this->scoutKey)
1106+
->all();
1107+
} elseif ($engine instanceof \Laravel\Scout\Engines\AlgoliaEngine) {
1108+
/** @var \Algolia\AlgoliaSearch\SearchClient $engine */
1109+
$algolia = $engine->initIndex($this->scoutIndex);
1110+
1111+
$search_results = $algolia->search($searchKeyword, [
1112+
'offset' => 0,
1113+
'length' => $this->scoutMaxHits,
1114+
'attributesToRetrieve' => [$this->scoutKey],
1115+
'attributesToHighlight' => [],
1116+
'filters' => $searchFilters,
1117+
]);
1118+
1119+
/** @var array<int, array<string, mixed>> $hits */
1120+
$hits = $search_results['hits'] ?? [];
1121+
1122+
return collect($hits)
1123+
->pluck($this->scoutKey)
1124+
->all();
1125+
} else {
1126+
throw new \Exception('Unsupported Scout Engine. Currently supported: Meilisearch, Algolia');
1127+
}
1128+
}
8641129
}

0 commit comments

Comments
 (0)