Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.84% covered (success)
93.84%
137 / 146
52.94% covered (warning)
52.94%
9 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Engine
93.84% covered (success)
93.84%
137 / 146
52.94% covered (warning)
52.94%
9 / 17
52.63
0.00% covered (danger)
0.00%
0 / 1
 newSearchQuery
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 processString
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
13.15
 loadStopWords
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 update
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 delete
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 search
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 paginate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 performSearch
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
7
 applyQueryScopes
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 mapIds
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRecord
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 map
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 lazyMap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getTotalCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 flush
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deleteIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Baril\Sqlout;
4
5use Closure;
6use Exception;
7use Illuminate\Contracts\Support\Arrayable;
8use Illuminate\Support\Collection;
9use Laravel\Scout\Engines\Engine as ScoutEngine;
10use Laravel\Scout\Builder;
11
12class 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}