Completed
Push — master ( 08d577...287f6d )
by Nenad
02:21 queued 32s
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 Laravel\Scout\Builder;
6
use TeamTNT\TNTSearch\TNTSearch;
7
use Laravel\Scout\Engines\Engine;
8
use Illuminate\Support\Facades\DB;
9
use Illuminate\Database\Eloquent\Collection;
10
use Illuminate\Database\Eloquent\SoftDeletes;
11
use TeamTNT\TNTSearch\Exceptions\IndexNotFoundException;
12
13
class TNTSearchEngine extends Engine
14
{
15
    /**
16
     * @var TNTSearch
17
     */
18
    protected $tnt;
19
20
    /**
21
     * @var Builder
22
     */
23
    protected $builder;
24
25
    /**
26
     * Used to query database with given constraints.
27
     *
28
     * @var Builder
29
     */
30
    protected $query;
31
32
    /**
33
     * Create a new engine instance.
34
     *
35
     * @param TNTSearch $tnt
36
     */
37
    public function __construct(TNTSearch $tnt)
38
    {
39
        $this->tnt = $tnt;
40
    }
41
42
    /**
43
     * Update the given model in the index.
44
     *
45
     * @param Collection $models
46
     *
47
     * @return void
48
     */
49
    public function update($models)
50
    {
51
        $this->initIndex($models->first());
52
        $this->tnt->selectIndex("{$models->first()->searchableAs()}.index");
53
        $index = $this->tnt->getIndex();
54
        $index->setPrimaryKey($models->first()->getKeyName());
55
56
        $index->indexBeginTransaction();
57
        $models->each(function ($model) use ($index) {
58
            $array = $model->toSearchableArray();
59
60
            if (empty($array)) {
61
                return;
62
            }
63
64
            if ($model->getKey()) {
65
                $index->update($model->getKey(), $array);
66
            } else {
67
                $index->insert($array);
68
            }
69
        });
70
        $index->indexEndTransaction();
71
    }
72
73
    /**
74
     * Remove the given model from the index.
75
     *
76
     * @param Collection $models
77
     *
78
     * @return void
79
     */
80
    public function delete($models)
81
    {
82
        $this->initIndex($models->first());
83
        $models->each(function ($model) {
84
            $this->tnt->selectIndex("{$model->searchableAs()}.index");
85
            $index = $this->tnt->getIndex();
86
            $index->setPrimaryKey($model->getKeyName());
87
            $index->delete($model->getKey());
88
        });
89
    }
90
91
    /**
92
     * Perform the given search on the engine.
93
     *
94
     * @param Builder $builder
95
     *
96
     * @return mixed
97
     */
98
    public function search(Builder $builder)
99
    {
100
        try {
101
            return $this->performSearch($builder);
102
        } catch (IndexNotFoundException $e) {
103
            $this->initIndex($builder->model);
104
        }
105
    }
106
107
    /**
108
     * Perform the given search on the engine.
109
     *
110
     * @param Builder $builder
111
     * @param int     $perPage
112
     * @param int     $page
113
     *
114
     * @return mixed
115
     */
116
    public function paginate(Builder $builder, $perPage, $page)
117
    {
118
        $results = $this->performSearch($builder);
119
120
        if ($builder->limit) {
121
            $results['hits'] = $builder->limit;
122
        }
123
124
        $filtered = $this->discardIdsFromResultSetByConstraints($builder, $results['ids']);
125
126
        $results['hits'] = $filtered->count();
127
128
        $chunks = array_chunk($filtered->toArray(), $perPage);
129
130
        if (empty($chunks)) {
131
            return $results;
132
        }
133
134
        if (array_key_exists($page - 1, $chunks)) {
135
            $results['ids'] = $chunks[$page - 1];
136
        } else {
137
            $results['ids'] = end($chunks);
138
        }
139
140
        return $results;
141
    }
142
143
    /**
144
     * Perform the given search on the engine.
145
     *
146
     * @param Builder $builder
147
     *
148
     * @return mixed
149
     */
150
    protected function performSearch(Builder $builder, array $options = [])
151
    {
152
        $index = $builder->index ?: $builder->model->searchableAs();
153
        $limit = $builder->limit ?: 10000;
154
        $this->tnt->selectIndex("{$index}.index");
155
156
        $this->builder = $builder;
157
158
        if (isset($builder->model->asYouType)) {
159
            $this->tnt->asYouType = $builder->model->asYouType;
160
        }
161
162
        if ($builder->callback) {
163
            return call_user_func(
164
                $builder->callback,
165
                $this->tnt,
166
                $builder->query,
167
                $options
168
            );
169
        }
170
        if (isset($this->tnt->config['searchBoolean']) ? $this->tnt->config['searchBoolean'] : false) {
171
            return $this->tnt->searchBoolean($builder->query, $limit);
172
        } else {
173
            return $this->tnt->search($builder->query, $limit);
174
        }
175
    }
176
177
    /**
178
     * Map the given results to instances of the given model.
179
     *
180
     * @param mixed                               $results
181
     * @param \Illuminate\Database\Eloquent\Model $model
182
     *
183
     * @return Collection
184
     */
185
    public function map(Builder $builder, $results, $model)
186
    {
187
        if (count($results['ids']) === 0) {
188
            return Collection::make();
189
        }
190
191
        $keys = collect($results['ids'])->values()->all();
192
193
        $builder = $this->getBuilder($model);
194
        
195
        if($this->builder->queryCallback){
196
            call_user_func($this->builder->queryCallback, $builder);
197
        }
198
199
        if($this->builder->queryCallback){
200
            call_user_func($this->builder->queryCallback, $builder);
201
        }
202
203
        $models = $builder->whereIn(
204
            $model->getQualifiedKeyName(), $keys
205
        )->get()->keyBy($model->getKeyName());
206
207
        // sort models by user choice
208
        if (!empty($this->builder->orders)) {
209
            return $models->values();
210
        }
211
212
        // sort models by tnt search result set
213
        return collect($results['ids'])->map(function ($hit) use ($models) {
214
            if (isset($models[$hit])) {
215
                return $models[$hit];
216
            }
217
        })->filter()->values();
218
    }
219
220
    /**
221
     * Return query builder either from given constraints, or as
222
     * new query. Add where statements to builder when given.
223
     *
224
     * @param \Illuminate\Database\Eloquent\Model $model
225
     *
226
     * @return Builder
227
     */
228
    public function getBuilder($model)
229
    {
230
        if (isset($this->query)) {
231
            return $this->query;
232
        }
233
234
        // get query as given constraint or create a new query
235
        $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...
236
237
        $builder = $this->handleSoftDeletes($builder, $model);
238
239
        $builder = $this->applyWheres($builder);
240
241
        $builder = $this->applyOrders($builder);
0 ignored issues
show
Documentation introduced by
$builder is of type object<Illuminate\Support\Collection>, but the function expects a object<Laravel\Scout\Builder>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
242
243
        return $this->query = $builder;
0 ignored issues
show
Documentation Bug introduced by
It seems like $builder of type object<Illuminate\Support\Collection> is incompatible with the declared type object<Laravel\Scout\Builder> of property $query.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
Bug Best Practice introduced by
The return type of return $this->query = $builder; (Illuminate\Support\Collection) is incompatible with the return type documented by TeamTNT\Scout\Engines\TNTSearchEngine::getBuilder of type Laravel\Scout\Builder.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
244
    }
245
246
    /**
247
     * Pluck and return the primary keys of the given results.
248
     *
249
     * @param mixed $results
250
     * @return \Illuminate\Support\Collection
251
     */
252
    public function mapIds($results)
253
    {
254
        return collect($results['ids'])->values();
255
    }
256
257
    /**
258
     * Get the total count from a raw result returned by the engine.
259
     *
260
     * @param mixed $results
261
     *
262
     * @return int
263
     */
264
    public function getTotalCount($results)
265
    {
266
        return $results['hits'];
267
    }
268
269
    public function initIndex($model)
270
    {
271
        $indexName = $model->searchableAs();
272
273
        if (!file_exists($this->tnt->config['storage']."/{$indexName}.index")) {
274
            $indexer = $this->tnt->createIndex("$indexName.index");
275
            $indexer->setDatabaseHandle($model->getConnection()->getPdo());
276
            $indexer->setPrimaryKey($model->getKeyName());
277
        }
278
    }
279
280
    /**
281
     * The search index results ($results['ids']) need to be compared against our query
282
     * that contains the constraints.
283
     *
284
     * To get the correct results and counts for the pagination, we remove those ids
285
     * from the search index results that were found by the search but are not part of
286
     * the query ($sub) that is constrained.
287
     *
288
     * This is achieved with self joining the constrained query as subquery and selecting
289
     * the ids which are not matching to anything (i.e., is null).
290
     *
291
     * The constraints usually remove only a small amount of results, which is why the non
292
     * matching results are looked up and removed, instead of returning a collection with
293
     * all the valid results.
294
     */
295
    private function discardIdsFromResultSetByConstraints($builder, $searchResults)
296
    {
297
        $qualifiedKeyName = $builder->model->getQualifiedKeyName();  // tableName.id
298
        $subQualifiedKeyName = 'sub.' . $builder->model->getKeyName(); // sub.id
299
300
        $sub = $this->getBuilder($builder->model)->whereIn(
301
            $qualifiedKeyName, $searchResults
302
        ); // sub query for left join
303
304
        $discardIds = $builder->model->newQuery()
305
            ->select($qualifiedKeyName)
306
            ->leftJoin(DB::raw('(' . $sub->getQuery()->toSql() .') as sub'), $subQualifiedKeyName, '=', $qualifiedKeyName)
307
            ->addBinding($sub->getQuery()->getBindings(), 'join')
308
            ->whereIn($qualifiedKeyName, $searchResults)
309
            ->whereNull($subQualifiedKeyName)
310
            ->pluck($builder->model->getKeyName());
311
312
        // returns values of $results['ids'] that are not part of $discardIds
313
        return collect($searchResults)->diff($discardIds);
314
    }
315
316
    /**
317
     * Determine if the given model uses soft deletes.
318
     *
319
     * @param  \Illuminate\Database\Eloquent\Model  $model
320
     * @return bool
321
     */
322
    protected function usesSoftDelete($model)
323
    {
324
        return in_array(SoftDeletes::class, class_uses_recursive($model));
325
    }
326
327
    /**
328
     * Determine if soft delete is active and depending on state return the
329
     * appropriate builder.
330
     *
331
     * @param  Builder  $builder
332
     * @param  \Illuminate\Database\Eloquent\Model  $model
333
     * @return Builder
334
     */
335
    private function handleSoftDeletes($builder, $model)
336
    {
337
        // remove where statement for __soft_deleted when soft delete is not active
338
        // does not show soft deleted items when trait is attached to model and
339
        // config('scout.soft_delete') is false
340
        if (!$this->usesSoftDelete($model) || !config('scout.soft_delete', true)) {
341
            unset($this->builder->wheres['__soft_deleted']);
342
            return $builder;
343
        }
344
345
        /**
346
         * Use standard behaviour of Laravel Scout builder class to support soft deletes.
347
         *
348
         * When no __soft_deleted statement is given return all entries
349
         */
350
        if (!in_array('__soft_deleted', $this->builder->wheres)) {
351
            return $builder->withTrashed();
352
        }
353
354
        /**
355
         * When __soft_deleted is 1 then return only soft deleted entries
356
         */
357
        if ($this->builder->wheres['__soft_deleted']) {
358
            $builder = $builder->onlyTrashed();
359
        }
360
361
        /**
362
         * Returns all undeleted entries, default behaviour
363
         */
364
        unset($this->builder->wheres['__soft_deleted']);
365
        return $builder;
366
    }
367
368
    /**
369
     * Apply where statements as constraints to the query builder.
370
     *
371
     * @param Builder $builder
372
     * @return \Illuminate\Support\Collection
373
     */
374
    private function applyWheres($builder)
375
    {
376
        // iterate over given where clauses
377
        return collect($this->builder->wheres)->map(function ($value, $key) {
378
            // for reduce function combine key and value into array
379
            return [$key, $value];
380
        })->reduce(function ($builder, $where) {
381
            // separate key, value again
382
            list($key, $value) = $where;
383
            return $builder->where($key, $value);
384
        }, $builder);
385
    }
386
387
    /**
388
     * Apply order by statements as constraints to the query builder.
389
     *
390
     * @param Builder $builder
391
     * @return \Illuminate\Support\Collection
392
     */
393
    private function applyOrders($builder)
394
    {
395
        //iterate over given orderBy clauses - should be only one
396
        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...
397
            // for reduce function combine key and value into array
398
            return [$value["column"], $value["direction"]];
399
        })->reduce(function ($builder, $orderBy) {
400
            // separate key, value again
401
            list($column, $direction) = $orderBy;
402
            return $builder->orderBy($column, $direction);
403
        }, $builder);
404
    }
405
}
406