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