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

TNTSearchEngine   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 459
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 10

Importance

Changes 0
Metric Value
wmc 55
lcom 1
cbo 10
dl 0
loc 459
rs 6
c 0
b 0
f 0

17 Methods

Rating   Name   Duplication   Size   Complexity  
A mapIds() 0 4 1
A getTotalCount() 0 4 1
A __construct() 0 5 1
B update() 0 46 9
A delete() 0 21 2
A search() 0 8 2
A paginate() 0 26 4
B performSearch() 0 37 10
B map() 0 30 6
A getBuilder() 0 13 2
A initIndex() 0 17 4
A discardIdsFromResultSetByConstraints() 0 20 1
A usesSoftDelete() 0 4 1
A handleSoftDeletes() 0 32 5
A applyWheres() 0 12 1
A applyOrders() 0 12 1
A flush() 0 15 4

How to fix   Complexity   

Complex Class

Complex classes like TNTSearchEngine often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use TNTSearchEngine, and based on these observations, apply Extract Interface, too.

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