Completed
Push — master ( a8a4c3...3555f5 )
by Nenad
13:53
created

TNTSearchEngine::addFilter()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 7
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 2
1
<?php
2
3
namespace TeamTNT\Scout\Engines;
4
5
use Illuminate\Database\Eloquent\Collection;
6
use Illuminate\Database\Eloquent\SoftDeletes;
7
use Illuminate\Support\Facades\DB;
8
use Laravel\Scout\Builder;
9
use Laravel\Scout\Engines\Engine;
10
use TeamTNT\Scout\Events\SearchPerformed;
11
use TeamTNT\TNTSearch\Exceptions\IndexNotFoundException;
12
use TeamTNT\TNTSearch\TNTSearch;
13
14
class TNTSearchEngine extends Engine
15
{
16
17
    private $filters;
18
    /**
19
     * @var TNTSearch
20
     */
21
    protected $tnt;
22
23
    /**
24
     * @var Builder
25
     */
26
    protected $builder;
27
28
    /**
29
     * Create a new engine instance.
30
     *
31
     * @param TNTSearch $tnt
32
     */
33
    public function __construct(TNTSearch $tnt)
34
    {
35
        $this->tnt = $tnt;
36
    }
37
38
    /**
39
     * Update the given model in the index.
40
     *
41
     * @param Collection $models
42
     *
43
     * @return void
44
     */
45
    public function update($models)
46
    {
47
        $this->initIndex($models->first());
48
        $this->tnt->selectIndex("{$models->first()->searchableAs()}.index");
49
        $index = $this->tnt->getIndex();
50
        $index->setPrimaryKey($models->first()->getKeyName());
51
52
        $index->indexBeginTransaction();
53
        $models->each(function ($model) use ($index) {
54
            $array = $model->toSearchableArray();
55
56
            if (empty($array)) {
57
                return;
58
            }
59
60
            if ($model->getKey()) {
61
                $index->update($model->getKey(), $array);
62
            } else {
63
                $index->insert($array);
64
            }
65
        });
66
        $index->indexEndTransaction();
67
    }
68
69
    /**
70
     * Remove the given model from the index.
71
     *
72
     * @param Collection $models
73
     *
74
     * @return void
75
     */
76
    public function delete($models)
77
    {
78
        $this->initIndex($models->first());
79
        $models->each(function ($model) {
80
            $this->tnt->selectIndex("{$model->searchableAs()}.index");
81
            $index = $this->tnt->getIndex();
82
            $index->setPrimaryKey($model->getKeyName());
83
            $index->delete($model->getKey());
84
        });
85
    }
86
87
    /**
88
     * Perform the given search on the engine.
89
     *
90
     * @param Builder $builder
91
     *
92
     * @return mixed
93
     */
94
    public function search(Builder $builder)
95
    {
96
        try {
97
            return $this->performSearch($builder);
98
        } catch (IndexNotFoundException $e) {
99
            $this->initIndex($builder->model);
100
        }
101
    }
102
103
    /**
104
     * Perform the given search on the engine.
105
     *
106
     * @param Builder $builder
107
     * @param int     $perPage
108
     * @param int     $page
109
     *
110
     * @return mixed
111
     */
112
    public function paginate(Builder $builder, $perPage, $page)
113
    {
114
        $results = $this->performSearch($builder);
115
116
        if ($builder->limit) {
117
            $results['hits'] = $builder->limit;
118
        }
119
120
        $filtered = $this->discardIdsFromResultSetByConstraints($builder, $results['ids']);
121
122
        $results['hits'] = $filtered->count();
123
124
        $chunks = array_chunk($filtered->toArray(), $perPage);
125
126
        if (empty($chunks)) {
127
            return $results;
128
        }
129
130
        if (array_key_exists($page - 1, $chunks)) {
131
            $results['ids'] = $chunks[$page - 1];
132
        } else {
133
            $results['ids'] = [];
134
        }
135
136
        return $results;
137
    }
138
139
    /**
140
     * Perform the given search on the engine.
141
     *
142
     * @param Builder $builder
143
     *
144
     * @return mixed
145
     */
146
    protected function performSearch(Builder $builder, array $options = [])
147
    {
148
        $index = $builder->index ?: $builder->model->searchableAs();
149
        $limit = $builder->limit ?: 10000;
150
        $this->tnt->selectIndex("{$index}.index");
151
152
        $this->builder = $builder;
153
154
        if (isset($builder->model->asYouType)) {
155
            $this->tnt->asYouType = $builder->model->asYouType;
156
        }
157
158
        if ($builder->callback) {
159
            return call_user_func(
160
                $builder->callback,
161
                $this->tnt,
162
                $builder->query,
163
                $options
164
            );
165
        }
166
167
        $builder->query = $this->applyFilters('query_expansion', $builder->query, get_class($builder->model));
168
169
        if (isset($this->tnt->config['searchBoolean']) ? $this->tnt->config['searchBoolean'] : false) {
170
            $res = $this->tnt->searchBoolean($builder->query, $limit);
171
            event(new SearchPerformed($builder, $res, true));
172
            return $res;
173
174
        } else {
175
            $res = $this->tnt->search($builder->query, $limit);
176
            event(new SearchPerformed($builder, $res));
177
            return $res;
178
        }
179
    }
180
181
    /**
182
     * Map the given results to instances of the given model.
183
     *
184
     * @param mixed                               $results
185
     * @param \Illuminate\Database\Eloquent\Model $model
186
     *
187
     * @return Collection
188
     */
189
    public function map(Builder $builder, $results, $model)
190
    {
191
        if (is_null($results['ids']) || count($results['ids']) === 0) {
192
            return Collection::make();
193
        }
194
195
        $keys = collect($results['ids'])->values()->all();
196
197
        $builder = $this->getBuilder($model);
198
199
        if ($this->builder->queryCallback) {
200
            call_user_func($this->builder->queryCallback, $builder);
201
        }
202
203
        $models = $builder->whereIn(
204
            $model->getQualifiedKeyName(), $keys
205
        )->get()->keyBy($model->getKeyName());
206
207
        // sort models by user choice
208
        if (!empty($this->builder->orders)) {
209
            return $models->values();
210
        }
211
212
        // sort models by tnt search result set
213
        return $model->newCollection($results['ids'])->map(function ($hit) use ($models) {
214
            if (isset($models[$hit])) {
215
                return $models[$hit];
216
            }
217
        })->filter()->values();
218
    }
219
220
    /**
221
     * Return query builder either from given constraints, or as
222
     * new query. Add where statements to builder when given.
223
     *
224
     * @param \Illuminate\Database\Eloquent\Model $model
225
     *
226
     * @return Builder
227
     */
228
    public function getBuilder($model)
229
    {
230
        // get query as given constraint or create a new query
231
        $builder = isset($this->builder->constraints) ? $this->builder->constraints : $model->newQuery();
0 ignored issues
show
Bug introduced by
The property constraints does not seem to exist in Laravel\Scout\Builder.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
232
233
        $builder = $this->handleSoftDeletes($builder, $model);
234
235
        $builder = $this->applyWheres($builder);
236
237
        $builder = $this->applyOrders($builder);
238
239
        return $builder;
240
    }
241
242
    /**
243
     * Pluck and return the primary keys of the given results.
244
     *
245
     * @param mixed $results
246
     * @return \Illuminate\Support\Collection
247
     */
248
    public function mapIds($results)
249
    {
250
        return collect($results['ids'])->values();
251
    }
252
253
    /**
254
     * Get the total count from a raw result returned by the engine.
255
     *
256
     * @param mixed $results
257
     *
258
     * @return int
259
     */
260
    public function getTotalCount($results)
261
    {
262
        return $results['hits'];
263
    }
264
265
    public function initIndex($model)
266
    {
267
        $indexName = $model->searchableAs();
268
269
        if (!file_exists($this->tnt->config['storage']."/{$indexName}.index")) {
270
            $indexer = $this->tnt->createIndex("$indexName.index");
271
            $indexer->setDatabaseHandle($model->getConnection()->getPdo());
272
            $indexer->setPrimaryKey($model->getKeyName());
273
        }
274
    }
275
276
    /**
277
     * The search index results ($results['ids']) need to be compared against our query
278
     * that contains the constraints.
279
     *
280
     * To get the correct results and counts for the pagination, we remove those ids
281
     * from the search index results that were found by the search but are not part of
282
     * the query ($sub) that is constrained.
283
     *
284
     * This is achieved with self joining the constrained query as subquery and selecting
285
     * the ids which are not matching to anything (i.e., is null).
286
     *
287
     * The constraints usually remove only a small amount of results, which is why the non
288
     * matching results are looked up and removed, instead of returning a collection with
289
     * all the valid results.
290
     */
291
    private function discardIdsFromResultSetByConstraints($builder, $searchResults)
292
    {
293
        $qualifiedKeyName    = $builder->model->getQualifiedKeyName(); // tableName.id
294
        $subQualifiedKeyName = 'sub.'.$builder->model->getKeyName(); // sub.id
295
296
        $sub = $this->getBuilder($builder->model)->whereIn(
297
            $qualifiedKeyName, $searchResults
298
        ); // sub query for left join
299
300
        $discardIds = $builder->model->newQuery()
301
            ->select($qualifiedKeyName)
302
            ->leftJoin(DB::raw('('.$sub->getQuery()->toSql().') as '.$builder->model->getConnection()->getTablePrefix().'sub'), $subQualifiedKeyName, '=', $qualifiedKeyName)
303
            ->addBinding($sub->getQuery()->getBindings(), 'join')
304
            ->whereIn($qualifiedKeyName, $searchResults)
305
            ->whereNull($subQualifiedKeyName)
306
            ->pluck($builder->model->getKeyName());
307
308
        // returns values of $results['ids'] that are not part of $discardIds
309
        return collect($searchResults)->diff($discardIds);
310
    }
311
312
    /**
313
     * Determine if the given model uses soft deletes.
314
     *
315
     * @param  \Illuminate\Database\Eloquent\Model  $model
316
     * @return bool
317
     */
318
    protected function usesSoftDelete($model)
319
    {
320
        return in_array(SoftDeletes::class, class_uses_recursive($model));
321
    }
322
323
    /**
324
     * Determine if soft delete is active and depending on state return the
325
     * appropriate builder.
326
     *
327
     * @param  Builder  $builder
328
     * @param  \Illuminate\Database\Eloquent\Model  $model
329
     * @return Builder
330
     */
331
    private function handleSoftDeletes($builder, $model)
332
    {
333
        // remove where statement for __soft_deleted when soft delete is not active
334
        // does not show soft deleted items when trait is attached to model and
335
        // config('scout.soft_delete') is false
336
        if (!$this->usesSoftDelete($model) || !config('scout.soft_delete', true)) {
337
            unset($this->builder->wheres['__soft_deleted']);
338
            return $builder;
339
        }
340
341
        /**
342
         * Use standard behaviour of Laravel Scout builder class to support soft deletes.
343
         *
344
         * When no __soft_deleted statement is given return all entries
345
         */
346
        if (!in_array('__soft_deleted', $this->builder->wheres)) {
347
            return $builder->withTrashed();
348
        }
349
350
        /**
351
         * When __soft_deleted is 1 then return only soft deleted entries
352
         */
353
        if ($this->builder->wheres['__soft_deleted']) {
354
            $builder = $builder->onlyTrashed();
355
        }
356
357
        /**
358
         * Returns all undeleted entries, default behaviour
359
         */
360
        unset($this->builder->wheres['__soft_deleted']);
361
        return $builder;
362
    }
363
364
    /**
365
     * Apply where statements as constraints to the query builder.
366
     *
367
     * @param Builder $builder
368
     * @return \Illuminate\Support\Collection
369
     */
370
    private function applyWheres($builder)
371
    {
372
        // iterate over given where clauses
373
        return collect($this->builder->wheres)->map(function ($value, $key) {
374
            // for reduce function combine key and value into array
375
            return [$key, $value];
376
        })->reduce(function ($builder, $where) {
377
            // separate key, value again
378
            list($key, $value) = $where;
379
            return $builder->where($key, $value);
380
        }, $builder);
381
    }
382
383
    /**
384
     * Apply order by statements as constraints to the query builder.
385
     *
386
     * @param Builder $builder
387
     * @return \Illuminate\Support\Collection
388
     */
389
    private function applyOrders($builder)
390
    {
391
        //iterate over given orderBy clauses - should be only one
392
        return collect($this->builder->orders)->map(function ($value, $key) {
0 ignored issues
show
Unused Code introduced by
The parameter $key is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
393
            // for reduce function combine key and value into array
394
            return [$value["column"], $value["direction"]];
395
        })->reduce(function ($builder, $orderBy) {
396
            // separate key, value again
397
            list($column, $direction) = $orderBy;
398
            return $builder->orderBy($column, $direction);
399
        }, $builder);
400
    }
401
402
    /**
403
     * Flush all of the model's records from the engine.
404
     *
405
     * @param  \Illuminate\Database\Eloquent\Model  $model
406
     * @return void
407
     */
408
    public function flush($model)
409
    {
410
        $indexName   = $model->searchableAs();
411
        $pathToIndex = $this->tnt->config['storage']."/{$indexName}.index";
412
        if (file_exists($pathToIndex)) {
413
            unlink($pathToIndex);
414
        }
415
    }
416
417
    /**
418
     * Adds a filter
419
     *
420
     * @param  string
421
     * @param  callback
422
     * @return void
423
     */
424
    public function addFilter($name, $callback)
425
    {
426
        if (!is_callable($callback, true)) {
427
            throw new InvalidArgumentException(sprintf('Filter is an invalid callback: %s.', print_r($callback, true)));
428
        }
429
        $this->filters[$name][] = $callback;
430
    }
431
432
    /**
433
     * Returns an array of filters
434
     *
435
     * @param  string
436
     * @return array
437
     */
438
    public function getFilters($name)
439
    {
440
        return isset($this->filters[$name]) ? $this->filters[$name] : [];
441
    }
442
443
    /**
444
     * Returns a string on which a filter is applied
445
     *
446
     * @param  string
447
     * @param  string
448
     * @return string
449
     */
450
    public function applyFilters($name, $result, $model)
451
    {
452
        foreach ($this->getFilters($name) as $callback) {
453
            // prevent fatal errors, do your own warning or
454
            // exception here as you need it.
455
            if (!is_callable($callback)) {
456
                continue;
457
            }
458
459
            $result = call_user_func($callback, $result, $model);
460
        }
461
        return $result;
462
    }
463
}
464