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

TNTSearchEngine::map()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

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