Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.21% covered (warning)
86.21%
100 / 116
46.67% covered (danger)
46.67%
7 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Engine
86.21% covered (warning)
86.21%
100 / 116
46.67% covered (danger)
46.67%
7 / 15
44.20
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
 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
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 performSearch
89.80% covered (warning)
89.80%
44 / 49
0.00% covered (danger)
0.00%
0 / 1
12.15
 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 Illuminate\Support\LazyCollection;
10use Laravel\Scout\Engines\Engine as ScoutEngine;
11use Laravel\Scout\Builder;
12
13class 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}