Completed
Pull Request — master (#259)
by
unknown
01:22
created

TNTSearchEngine::search()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
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\TNTSearch\Exceptions\IndexNotFoundException;
11
use TeamTNT\TNTSearch\TNTGeoSearch;
12
use TeamTNT\TNTSearch\TNTSearch;
13
14
class TNTSearchEngine extends Engine
15
{
16
    /**
17
     * @var TNTSearch
18
     */
19
    protected $tnt;
20
21
    /**
22
     * @var TNTGeoSearch
23
     */
24
    protected $geotnt;
25
26
    /**
27
     * @var Builder
28
     */
29
    protected $builder;
30
31
    /**
32
     * Create a new engine instance.
33
     *
34
     * @param TNTSearch $tnt
35
     * @param TNTGeoSearch|null $geotnt
36
     */
37
    public function __construct(TNTSearch $tnt, TNTGeoSearch $geotnt = null)
38
    {
39
        $this->tnt = $tnt;
40
        $this->geotnt = $geotnt;
41
    }
42
43
    /**
44
     * Update the given model in the index.
45
     *
46
     * @param Collection $models
47
     *
48
     * @return void
49
     */
50
    public function update($models)
51
    {
52
        $this->initIndex($models->first());
53
        $this->tnt->selectIndex("{$models->first()->searchableAs()}.index");
54
        $index = $this->tnt->getIndex();
55
        $index->setPrimaryKey($models->first()->getKeyName());
56
57
        $geoindex = null;
58 View Code Duplication
        if ($this->geotnt) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
59
            $this->geotnt->selectIndex("{$models->first()->searchableAs()}.index");
60
            $geoindex = $this->geotnt->getIndex();
61
            $geoindex->setPrimaryKey($models->first()->getKeyName());
62
        }
63
64
        $index->indexBeginTransaction();
65
        $models->each(function ($model) use ($index, $geoindex) {
66
            $array = $model->toSearchableArray();
67
68
            if (empty($array)) {
69
                return;
70
            }
71
72
            if ($model->getKey()) {
73
                if ($geoindex) {
74
                    $geoindex->delete($model->getKey());
75
                }
76
                $index->update($model->getKey(), $array);
77
            } else {
78
                $index->insert($array);
79
            }
80
            if ($geoindex && !empty($array['longitude']) && !empty($array['latitude'])) {
81
                $array['longitude'] = (float) $array['longitude'];
82
                $array['latitude'] = (float) $array['latitude'];
83
                $geoindex->insert($array);
84
            }
85
        });
86
        $index->indexEndTransaction();
87
    }
88
89
    /**
90
     * Remove the given model from the index.
91
     *
92
     * @param Collection $models
93
     *
94
     * @return void
95
     */
96
    public function delete($models)
97
    {
98
        $this->initIndex($models->first());
99
        $models->each(function ($model) {
100
            $this->tnt->selectIndex("{$model->searchableAs()}.index");
101
            $index = $this->tnt->getIndex();
102
            $index->setPrimaryKey($model->getKeyName());
103
            $index->delete($model->getKey());
104
105 View Code Duplication
            if ($this->geotnt) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
106
                $this->geotnt->selectIndex("{$model->searchableAs()}.index");
107
                $index = $this->geotnt->getIndex();
108
                $index->setPrimaryKey($model->getKeyName());
109
                $index->delete($model->getKey());
110
            }
111
        });
112
    }
113
114
    /**
115
     * Perform the given search on the engine.
116
     *
117
     * @param Builder $builder
118
     *
119
     * @return mixed
120
     */
121
    public function search(Builder $builder)
122
    {
123
        try {
124
            return $this->performSearch($builder);
125
        } catch (IndexNotFoundException $e) {
126
            $this->initIndex($builder->model);
127
        }
128
    }
129
130
    /**
131
     * Perform the given search on the engine.
132
     *
133
     * @param Builder $builder
134
     * @param int     $perPage
135
     * @param int     $page
136
     *
137
     * @return mixed
138
     */
139
    public function paginate(Builder $builder, $perPage, $page)
140
    {
141
        $results = $this->performSearch($builder);
142
143
        if ($builder->limit) {
144
            $results['hits'] = $builder->limit;
145
        }
146
147
        $filtered = $this->discardIdsFromResultSetByConstraints($builder, $results['ids']);
148
149
        $results['hits'] = $filtered->count();
150
151
        $chunks = array_chunk($filtered->toArray(), $perPage);
152
153
        if (empty($chunks)) {
154
            return $results;
155
        }
156
157
        if (array_key_exists($page - 1, $chunks)) {
158
            $results['ids'] = $chunks[$page - 1];
159
        } else {
160
            $results['ids'] = [];
161
        }
162
163
        return $results;
164
    }
165
166
    /**
167
     * Perform the given search on the engine.
168
     *
169
     * @param Builder $builder
170
     *
171
     * @return mixed
172
     */
173
    protected function performSearch(Builder $builder, array $options = [])
174
    {
175
        $index = $builder->index ?: $builder->model->searchableAs();
176
        $limit = $builder->limit ?: 10000;
177
        $this->tnt->selectIndex("{$index}.index");
178
        if ($this->geotnt) {
179
            $this->geotnt->selectIndex("{$index}.index");
180
        }
181
182
        $this->builder = $builder;
183
184
        if (isset($builder->model->asYouType)) {
185
            $this->tnt->asYouType = $builder->model->asYouType;
186
        }
187
188
        if ($builder->callback) {
189
            return call_user_func(
190
                $builder->callback,
191
                $this->tnt,
192
                $builder->query,
193
                $options
194
            );
195
        }
196
197
        if (is_array($builder->query)) {
198
            $location = $builder->query['location'];
199
            $distance = $builder->query['distance'];
200
            $limit = array_key_exists('limit', $builder->query) ? $builder->query['limit'] : 10;
201
            return $this->geotnt->findNearest($location, $distance, $limit);
202
        }
203
204
        if (isset($this->tnt->config['searchBoolean']) ? $this->tnt->config['searchBoolean'] : false) {
205
            return $this->tnt->searchBoolean($builder->query, $limit);
206
        } else {
207
            return $this->tnt->search($builder->query, $limit);
208
        }
209
    }
210
211
    /**
212
     * Map the given results to instances of the given model.
213
     *
214
     * @param mixed                               $results
215
     * @param \Illuminate\Database\Eloquent\Model $model
216
     *
217
     * @return Collection
218
     */
219
    public function map(Builder $builder, $results, $model)
220
    {
221
        if (is_null($results['ids']) || count($results['ids']) === 0) {
222
            return Collection::make();
223
        }
224
225
        $keys = collect($results['ids'])->values()->all();
226
227
        $builder = $this->getBuilder($model);
228
229
        if ($this->builder->queryCallback) {
230
            call_user_func($this->builder->queryCallback, $builder);
231
        }
232
233
        $models = $builder->whereIn(
234
            $model->getQualifiedKeyName(), $keys
235
        )->get()->keyBy($model->getKeyName());
236
237
        // sort models by user choice
238
        if (!empty($this->builder->orders)) {
239
            return $models->values();
240
        }
241
242
        // sort models by tnt search result set
243
        return collect($results['ids'])->map(function ($hit) use ($models) {
244
            if (isset($models[$hit])) {
245
                return $models[$hit];
246
            }
247
        })->filter()->values();
248
    }
249
250
    /**
251
     * Return query builder either from given constraints, or as
252
     * new query. Add where statements to builder when given.
253
     *
254
     * @param \Illuminate\Database\Eloquent\Model $model
255
     *
256
     * @return Builder
257
     */
258
    public function getBuilder($model)
259
    {
260
        // get query as given constraint or create a new query
261
        $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...
262
263
        $builder = $this->handleSoftDeletes($builder, $model);
264
265
        $builder = $this->applyWheres($builder);
266
267
        $builder = $this->applyOrders($builder);
268
269
        return $builder;
270
    }
271
272
    /**
273
     * Pluck and return the primary keys of the given results.
274
     *
275
     * @param mixed $results
276
     * @return \Illuminate\Support\Collection
277
     */
278
    public function mapIds($results)
279
    {
280
        return collect($results['ids'])->values();
281
    }
282
283
    /**
284
     * Get the total count from a raw result returned by the engine.
285
     *
286
     * @param mixed $results
287
     *
288
     * @return int
289
     */
290
    public function getTotalCount($results)
291
    {
292
        return $results['hits'];
293
    }
294
295
    public function initIndex($model)
296
    {
297
        $indexName = $model->searchableAs();
298
299
        if (!file_exists($this->tnt->config['storage']."/{$indexName}.index")) {
300
            $indexer = $this->tnt->createIndex("$indexName.index");
301
            $indexer->setDatabaseHandle($model->getConnection()->getPdo());
302
            $indexer->setPrimaryKey($model->getKeyName());
303
304
            if (!$this->geotnt) {
305
                $indexer = $this->geotnt->getIndex()->createIndex("$indexName.index");
306
                $indexer->setDatabaseHandle($model->getConnection()->getPdo());
307
                $indexer->setPrimaryKey($model->getKeyName());
308
            }
309
        }
310
    }
311
312
    /**
313
     * The search index results ($results['ids']) need to be compared against our query
314
     * that contains the constraints.
315
     *
316
     * To get the correct results and counts for the pagination, we remove those ids
317
     * from the search index results that were found by the search but are not part of
318
     * the query ($sub) that is constrained.
319
     *
320
     * This is achieved with self joining the constrained query as subquery and selecting
321
     * the ids which are not matching to anything (i.e., is null).
322
     *
323
     * The constraints usually remove only a small amount of results, which is why the non
324
     * matching results are looked up and removed, instead of returning a collection with
325
     * all the valid results.
326
     */
327
    private function discardIdsFromResultSetByConstraints($builder, $searchResults)
328
    {
329
        $qualifiedKeyName    = $builder->model->getQualifiedKeyName(); // tableName.id
330
        $subQualifiedKeyName = 'sub.'.$builder->model->getKeyName(); // sub.id
331
332
        $sub = $this->getBuilder($builder->model)->whereIn(
333
            $qualifiedKeyName, $searchResults
334
        ); // sub query for left join
335
336
        $discardIds = $builder->model->newQuery()
337
            ->select($qualifiedKeyName)
338
            ->leftJoin(DB::raw('('.$sub->getQuery()->toSql().') as '. $builder->model->getConnection()->getTablePrefix() .'sub'), $subQualifiedKeyName, '=', $qualifiedKeyName)
339
            ->addBinding($sub->getQuery()->getBindings(), 'join')
340
            ->whereIn($qualifiedKeyName, $searchResults)
341
            ->whereNull($subQualifiedKeyName)
342
            ->pluck($builder->model->getKeyName());
343
344
        // returns values of $results['ids'] that are not part of $discardIds
345
        return collect($searchResults)->diff($discardIds);
346
    }
347
348
    /**
349
     * Determine if the given model uses soft deletes.
350
     *
351
     * @param  \Illuminate\Database\Eloquent\Model  $model
352
     * @return bool
353
     */
354
    protected function usesSoftDelete($model)
355
    {
356
        return in_array(SoftDeletes::class, class_uses_recursive($model));
357
    }
358
359
    /**
360
     * Determine if soft delete is active and depending on state return the
361
     * appropriate builder.
362
     *
363
     * @param  Builder  $builder
364
     * @param  \Illuminate\Database\Eloquent\Model  $model
365
     * @return Builder
366
     */
367
    private function handleSoftDeletes($builder, $model)
368
    {
369
        // remove where statement for __soft_deleted when soft delete is not active
370
        // does not show soft deleted items when trait is attached to model and
371
        // config('scout.soft_delete') is false
372
        if (!$this->usesSoftDelete($model) || !config('scout.soft_delete', true)) {
373
            unset($this->builder->wheres['__soft_deleted']);
374
            return $builder;
375
        }
376
377
        /**
378
         * Use standard behaviour of Laravel Scout builder class to support soft deletes.
379
         *
380
         * When no __soft_deleted statement is given return all entries
381
         */
382
        if (!in_array('__soft_deleted', $this->builder->wheres)) {
383
            return $builder->withTrashed();
384
        }
385
386
        /**
387
         * When __soft_deleted is 1 then return only soft deleted entries
388
         */
389
        if ($this->builder->wheres['__soft_deleted']) {
390
            $builder = $builder->onlyTrashed();
391
        }
392
393
        /**
394
         * Returns all undeleted entries, default behaviour
395
         */
396
        unset($this->builder->wheres['__soft_deleted']);
397
        return $builder;
398
    }
399
400
    /**
401
     * Apply where statements as constraints to the query builder.
402
     *
403
     * @param Builder $builder
404
     * @return \Illuminate\Support\Collection
405
     */
406
    private function applyWheres($builder)
407
    {
408
        // iterate over given where clauses
409
        return collect($this->builder->wheres)->map(function ($value, $key) {
410
            // for reduce function combine key and value into array
411
            return [$key, $value];
412
        })->reduce(function ($builder, $where) {
413
            // separate key, value again
414
            list($key, $value) = $where;
415
            return $builder->where($key, $value);
416
        }, $builder);
417
    }
418
419
    /**
420
     * Apply order by statements as constraints to the query builder.
421
     *
422
     * @param Builder $builder
423
     * @return \Illuminate\Support\Collection
424
     */
425
    private function applyOrders($builder)
426
    {
427
        //iterate over given orderBy clauses - should be only one
428
        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...
429
            // for reduce function combine key and value into array
430
            return [$value["column"], $value["direction"]];
431
        })->reduce(function ($builder, $orderBy) {
432
            // separate key, value again
433
            list($column, $direction) = $orderBy;
434
            return $builder->orderBy($column, $direction);
435
        }, $builder);
436
    }
437
438
    /**
439
     * Flush all of the model's records from the engine.
440
     *
441
     * @param  \Illuminate\Database\Eloquent\Model  $model
442
     * @return void
443
     */
444
    public function flush($model)
445
    {
446
        $indexName   = $model->searchableAs();
447
        $pathToIndex = $this->tnt->config['storage']."/{$indexName}.index";
448
        if (file_exists($pathToIndex)) {
449
            unlink($pathToIndex);
450
        }
451
452
        if ($this->geotnt){
453
            $pathToGeoIndex = $this->geotnt->config['storage']."/{$indexName}.index";
454
            if (file_exists($pathToGeoIndex)) {
455
                unlink($pathToGeoIndex);
456
            }
457
        }
458
    }
459
}
460