Completed
Push — master ( 287f6d...e4050c )
by Nenad
10s
created

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