5
5
use Illuminate \Contracts \Database \Eloquent \Builder as EloquentBuilder ;
6
6
use Illuminate \Contracts \Database \Query \Builder as QueryBuilder ;
7
7
use Illuminate \Database \Connection ;
8
+ use Illuminate \Database \Eloquent \Model ;
8
9
use Illuminate \Database \Query \Expression ;
9
10
use Illuminate \Http \JsonResponse ;
10
11
use Illuminate \Support \Collection ;
@@ -56,6 +57,56 @@ class QueryDataTable extends DataTableAbstract
56
57
*/
57
58
protected bool $ ignoreSelectInCountQuery = false ;
58
59
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
+
59
110
/**
60
111
* @param QueryBuilder $builder
61
112
*/
@@ -237,7 +288,7 @@ protected function filterRecords(): void
237
288
}
238
289
239
290
if (is_callable ($ this ->filterCallback )) {
240
- call_user_func ($ this ->filterCallback , $ this ->resolveCallbackParameter ());
291
+ call_user_func_array ($ this ->filterCallback , $ this ->resolveCallbackParameter ());
241
292
}
242
293
243
294
$ this ->columnSearch ();
@@ -673,11 +724,11 @@ protected function searchPanesSearch(): void
673
724
/**
674
725
* Resolve callback parameter instance.
675
726
*
676
- * @return QueryBuilder
727
+ * @return array<int|string, mixed>
677
728
*/
678
- protected function resolveCallbackParameter ()
729
+ protected function resolveCallbackParameter (): array
679
730
{
680
- return $ this ->query ;
731
+ return [ $ this ->query , $ this -> scoutSearched ] ;
681
732
}
682
733
683
734
/**
@@ -778,6 +829,11 @@ protected function getNullsLastSql($column, $direction): string
778
829
*/
779
830
protected function globalSearch (string $ keyword ): void
780
831
{
832
+ // Try scout search first & fall back to default search if disabled/failed
833
+ if ($ this ->applyScoutSearch ($ keyword )) {
834
+ return ;
835
+ }
836
+
781
837
$ this ->query ->where (function ($ query ) use ($ keyword ) {
782
838
collect ($ this ->request ->searchableColumnIndex ())
783
839
->map (function ($ index ) {
@@ -835,6 +891,9 @@ protected function attachAppends(array $data): array
835
891
}
836
892
}
837
893
894
+ // Set flag to disable ordering
895
+ $ appends ['disableOrdering ' ] = $ this ->disableUserOrdering ;
896
+
838
897
return array_merge ($ data , $ appends );
839
898
}
840
899
@@ -861,4 +920,210 @@ public function ignoreSelectsInCountQuery(): static
861
920
862
921
return $ this ;
863
922
}
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
+ }
864
1129
}
0 commit comments