Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.21% |
100 / 116 |
|
46.67% |
7 / 15 |
CRAP | |
0.00% |
0 / 1 |
Engine | |
86.21% |
100 / 116 |
|
46.67% |
7 / 15 |
44.20 | |
0.00% |
0 / 1 |
newSearchQuery | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
processString | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
13.15 | |||
update | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
delete | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
search | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
paginate | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
performSearch | |
89.80% |
44 / 49 |
|
0.00% |
0 / 1 |
12.15 | |||
mapIds | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRecord | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
map | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
lazyMap | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getTotalCount | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
flush | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
createIndex | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
deleteIndex | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace Baril\Sqlout; |
4 | |
5 | use Closure; |
6 | use Exception; |
7 | use Illuminate\Contracts\Support\Arrayable; |
8 | use Illuminate\Support\Collection; |
9 | use Illuminate\Support\LazyCollection; |
10 | use Laravel\Scout\Engines\Engine as ScoutEngine; |
11 | use Laravel\Scout\Builder; |
12 | |
13 | class Engine extends ScoutEngine |
14 | { |
15 | protected function newSearchQuery($model) |
16 | { |
17 | $query = SearchIndex::query(); |
18 | $searchModel = $query->getModel() |
19 | ->setConnection($model->getConnectionName()) |
20 | ->setTable(config('scout.sqlout.table_name')); |
21 | return $query->setModel($searchModel); |
22 | } |
23 | |
24 | /** |
25 | * Apply the filters to the indexed content or search terms, tokenize it |
26 | * and stem the words. |
27 | * |
28 | * @param string $content |
29 | * @return string |
30 | */ |
31 | protected function processString($content) |
32 | { |
33 | // Apply custom filters: |
34 | foreach (config('scout.sqlout.filters', []) as $filter) { |
35 | if (is_callable($filter)) { |
36 | $content = call_user_func($filter, $content); |
37 | } |
38 | } |
39 | |
40 | // Tokenize: |
41 | $words = preg_split(config('scout.sqlout.token_delimiter', '/[\s]+/'), $content); |
42 | |
43 | // Remove stopwords & short words: |
44 | $minLength = config('scout.sqlout.minimum_length', 0); |
45 | $stopwords = config('scout.sqlout.stopwords', []); |
46 | $words = (new Collection($words))->reject(function ($word) use ($minLength, $stopwords) { |
47 | return mb_strlen($word) < $minLength || in_array($word, $stopwords); |
48 | })->all(); |
49 | |
50 | // Stem: |
51 | $stemmer = config('scout.sqlout.stemmer'); |
52 | if ($stemmer) { |
53 | if (is_string($stemmer) && class_exists($stemmer) && method_exists($stemmer, 'stem')) { |
54 | $stemmer = [new $stemmer(), 'stem']; |
55 | } |
56 | if (is_object($stemmer) && method_exists($stemmer, 'stem')) { |
57 | foreach ($words as $k => $word) { |
58 | $words[$k] = $stemmer->stem($word); |
59 | } |
60 | } elseif (is_callable($stemmer)) { |
61 | foreach ($words as $k => $word) { |
62 | $words[$k] = call_user_func($stemmer, $word); |
63 | } |
64 | } else { |
65 | throw new Exception('Invalid stemmer!'); |
66 | } |
67 | } |
68 | |
69 | // Return result: |
70 | return implode(' ', $words); |
71 | } |
72 | |
73 | /** |
74 | * Update the given model in the index. |
75 | * |
76 | * @param \Illuminate\Database\Eloquent\Collection $models |
77 | * @return void |
78 | */ |
79 | public function update($models) |
80 | { |
81 | $models->each(function ($model) { |
82 | $type = $model->getMorphClass(); |
83 | $id = $model->getKey(); |
84 | $this->newSearchQuery($model)->where('record_type', $type)->where('record_id', $id)->delete(); |
85 | |
86 | $data = $model->toSearchableArray(); |
87 | foreach (array_filter($data) as $field => $content) { |
88 | $this->newSearchQuery($model)->create([ |
89 | 'record_type' => $type, |
90 | 'record_id' => $id, |
91 | 'field' => $field, |
92 | 'weight' => $model->getSearchWeight($field), |
93 | 'content' => $this->processString($content), |
94 | ]); |
95 | } |
96 | }); |
97 | } |
98 | |
99 | /** |
100 | * Remove the given model from the index. |
101 | * |
102 | * @param \Illuminate\Database\Eloquent\Collection $models |
103 | * @return void |
104 | */ |
105 | public function delete($models) |
106 | { |
107 | if (!$models->count()) { |
108 | return; |
109 | } |
110 | $this->newSearchQuery($models->first()) |
111 | ->where('record_type', $models->first()->getMorphClass()) |
112 | ->whereIn('record_id', $models->modelKeys()) |
113 | ->delete(); |
114 | } |
115 | |
116 | /** |
117 | * Perform the given search on the engine. |
118 | * |
119 | * @param \Laravel\Scout\Builder $builder |
120 | * @return mixed |
121 | */ |
122 | public function search(Builder $builder) |
123 | { |
124 | return $this->performSearch($builder, array_filter([ |
125 | 'hitsPerPage' => $builder->limit, |
126 | ])); |
127 | } |
128 | |
129 | /** |
130 | * Perform the given search on the engine. |
131 | * |
132 | * @param \Laravel\Scout\Builder $builder |
133 | * @param int $perPage |
134 | * @param int $page |
135 | * @return mixed |
136 | */ |
137 | public function paginate(Builder $builder, $perPage, $page) |
138 | { |
139 | return $this->performSearch($builder, [ |
140 | 'hitsPerPage' => $perPage, |
141 | 'page' => $page - 1, |
142 | ]); |
143 | } |
144 | |
145 | /** |
146 | * Perform the given search on the engine. |
147 | * |
148 | * @param \Laravel\Scout\Builder $builder |
149 | * @param array $options |
150 | * @return mixed |
151 | */ |
152 | protected function performSearch(Builder $builder, array $options = []) |
153 | { |
154 | $mode = $builder->mode ?? config('scout.sqlout.default_mode'); |
155 | $terms = $this->processString($builder->query); |
156 | |
157 | // Creating search query: |
158 | $query = $this->newSearchQuery($builder->model) |
159 | ->with('record') |
160 | ->where('record_type', $builder->model->getMorphClass()) |
161 | ->whereRaw("match(content) against (? $mode)", [$terms]) |
162 | ->groupBy('record_type') |
163 | ->groupBy('record_id') |
164 | ->selectRaw("sum(weight * (match(content) against (? $mode))) as _score", [$terms]) |
165 | ->addSelect(['record_type', 'record_id']); |
166 | foreach ($builder->wheres as $field => $value) { |
167 | if (is_array($value) || $value instanceof Arrayable) { |
168 | $query->whereIn($field, $value); |
169 | } else { |
170 | $query->where($field, $value); |
171 | } |
172 | } |
173 | |
174 | // Order clauses: |
175 | if (!$builder->orders) { |
176 | $builder->orderByScore(); |
177 | } |
178 | if ($builder->orders) { |
179 | foreach ($builder->orders as $i => $order) { |
180 | if ($order['column'] == '_score') { |
181 | $query->orderBy($order['column'], $order['direction']); |
182 | continue; |
183 | } |
184 | $alias = 'sqlout_reserved_order_' . $i; |
185 | $subQuery = $builder->model->newQuery() |
186 | ->select([ |
187 | $builder->model->getKeyName() . " as {$alias}_id", |
188 | $order['column'] . " as {$alias}_order", |
189 | ]); |
190 | $query->joinSub($subQuery, $alias, function ($join) use ($alias) { |
191 | $join->on('record_id', '=', $alias . '_id'); |
192 | }); |
193 | $query->orderBy($alias . '_order', $order['direction']); |
194 | } |
195 | } |
196 | |
197 | // Applying scopes to the model query: |
198 | $query->whereHasMorph('record', get_class($builder->model), function ($query) use ($builder) { |
199 | foreach ($builder->scopes as $scope) { |
200 | if ($scope instanceof Closure) { |
201 | $scope($query); |
202 | } else { |
203 | list($method, $parameters) = $scope; |
204 | $query->$method(...$parameters); |
205 | } |
206 | } |
207 | }); |
208 | |
209 | // Applying limit/offset: |
210 | if ($options['hitsPerPage'] ?? null) { |
211 | $query->limit($options['hitsPerPage']); |
212 | if ($options['page'] ?? null) { |
213 | $offset = $options['page'] * $options['hitsPerPage']; |
214 | $query->offset($offset); |
215 | } |
216 | } |
217 | |
218 | // Performing a first query to determine the total number of hits: |
219 | $countQuery = $query->getQuery() |
220 | ->cloneWithout(['groups', 'orders', 'offset', 'limit']) |
221 | ->cloneWithoutBindings(['order']); |
222 | $results = ['nbHits' => $countQuery->count($countQuery->getConnection()->raw('distinct record_id'))]; |
223 | |
224 | // Preparing the actual query: |
225 | $results['query'] = $query->with('record'); |
226 | |
227 | return $results; |
228 | } |
229 | |
230 | /** |
231 | * Pluck and return the primary keys of the given results. |
232 | * |
233 | * @param mixed $results |
234 | * @return \Illuminate\Support\Collection |
235 | */ |
236 | public function mapIds($results) |
237 | { |
238 | return $results['query']->pluck('record_id')->values(); |
239 | } |
240 | |
241 | /** |
242 | * Extract the Model from the search hit. |
243 | * |
244 | * @param SearchIndex $hit |
245 | * @return \Illuminate\Database\Eloquent\Model |
246 | */ |
247 | protected function getRecord($hit) |
248 | { |
249 | $hit->record->_score = $hit->_score; |
250 | return $hit->record; |
251 | } |
252 | |
253 | /** |
254 | * Map the given results to instances of the given model. |
255 | * |
256 | * @param \Laravel\Scout\Builder $builder |
257 | * @param mixed $results |
258 | * @param \Illuminate\Database\Eloquent\Model $model |
259 | * @return \Illuminate\Database\Eloquent\Collection |
260 | */ |
261 | public function map(Builder $builder, $results, $model) |
262 | { |
263 | $models = $results['query']->get()->map(function ($hit) { |
264 | return $this->getRecord($hit); |
265 | })->all(); |
266 | return $model->newCollection($models); |
267 | } |
268 | |
269 | /** |
270 | * Map the given results to instances of the given model via a lazy collection. |
271 | * |
272 | * @param \Laravel\Scout\Builder $builder |
273 | * @param mixed $results |
274 | * @param \Illuminate\Database\Eloquent\Model $model |
275 | * @return \Illuminate\Support\LazyCollection |
276 | */ |
277 | public function lazyMap(Builder $builder, $results, $model) |
278 | { |
279 | return $results['query']->lazy()->map(function ($hit) { |
280 | return $this->getRecord($hit); |
281 | }); |
282 | } |
283 | |
284 | /** |
285 | * Get the total count from a raw result returned by the engine. |
286 | * |
287 | * @param mixed $results |
288 | * @return int |
289 | */ |
290 | public function getTotalCount($results) |
291 | { |
292 | return $results['nbHits']; |
293 | } |
294 | |
295 | /** |
296 | * Flush all of the model's records from the engine. |
297 | * |
298 | * @param \Illuminate\Database\Eloquent\Model $model |
299 | * @return void |
300 | */ |
301 | public function flush($model) |
302 | { |
303 | $this->newSearchQuery($model)->where('record_type', $model->getMorphClass())->delete(); |
304 | } |
305 | |
306 | /** |
307 | * Create a search index. |
308 | * |
309 | * @param string $name |
310 | * @param array $options |
311 | * @return mixed |
312 | */ |
313 | public function createIndex($name, array $options = []) |
314 | { |
315 | // |
316 | } |
317 | |
318 | /** |
319 | * Delete a search index. |
320 | * |
321 | * @param string $name |
322 | * @return mixed |
323 | */ |
324 | public function deleteIndex($name) |
325 | { |
326 | // |
327 | } |
328 | } |