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

discardIdsFromResultSetByConstraints()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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