55use Illuminate \Contracts \Database \Eloquent \Builder as EloquentBuilder ;
66use Illuminate \Contracts \Database \Query \Builder as QueryBuilder ;
77use Illuminate \Database \Connection ;
8+ use Illuminate \Database \Eloquent \Model ;
89use Illuminate \Database \Query \Expression ;
910use Illuminate \Http \JsonResponse ;
1011use 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