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

TNTSearchEngine::performSearch()   B

Complexity

Conditions 10
Paths 80

Size

Total Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 37
rs 7.6666
c 0
b 0
f 0
cc 10
nc 80
nop 2

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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