Completed
Pull Request — master (#259)
by
unknown
04:11 queued 02:08
created

TNTSearchEngine::mapIds()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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