Coveralls logob
Coveralls logo
  • Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jarektkaczyk / eloquence / 204

2 Jul 2017 - 19:12 coverage: 91.815% (-0.2%) from 92.012%
204

Pull #185

travis-ci

9181eb84f9c35729a3bad740fb7f9d93?size=18&default=identiconweb-flow
fix: missing/wrong PHPDoc
Pull Request #185: fix: missing/wrong PHPDoc

931 of 1014 relevant lines covered (91.81%)

14.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.35
/src/Builder.php
1
<?php
2

3
namespace Sofa\Eloquence;
4

5
use Sofa\Eloquence\Searchable\Column;
6
use Illuminate\Database\Query\Expression;
7
use Sofa\Hookable\Builder as HookableBuilder;
8
use Sofa\Eloquence\Searchable\ColumnCollection;
9
use Sofa\Eloquence\Contracts\Relations\JoinerFactory;
10
use Sofa\Eloquence\Contracts\Searchable\ParserFactory;
11
use Illuminate\Database\Query\Grammars\PostgresGrammar;
12
use Sofa\Eloquence\Searchable\Subquery as SearchableSubquery;
13

14
/**
15
 * @method $this leftJoin($table, $one, $operator, $two)
16
 */
17
class Builder extends HookableBuilder
18
{
19
    /**
20
     * Parser factory instance.
21
     *
22
     * @var \Sofa\Eloquence\Contracts\Searchable\ParserFactory
23
     */
24
    protected static $parser;
25

26
    /**
27
     * Joiner factory instance.
28
     *
29
     * @var \Sofa\Eloquence\Contracts\Relations\JoinerFactory
30
     */
31
    protected static $joinerFactory;
32

33
    /**
34
     * Relations joiner instance.
35
     *
36
     * @var \Sofa\Eloquence\Contracts\Relations\Joiner
37
     */
38
    protected $joiner;
39

40
    /*
41
    |--------------------------------------------------------------------------
42
    | Additional features
43
    |--------------------------------------------------------------------------
44
    */
45

46
    /**
47
     * Execute the query as a "select" statement.
48
     *
49
     * @param  array $columns
50
     * @return \Illuminate\Database\Eloquent\Collection
51
     */
52
    public function get($columns = ['*'])
53
    {
54
        if ($this->query->from instanceof Subquery) {
4×
55
            $this->wheresToSubquery($this->query->from);
4×
56
        }
only 204.1 - 2×
57

58
        return parent::get($columns);
4×
59
    }
60

61
    /**
62
     * Search through any columns on current table or any defined relations
63
     * and return results ordered by search relevance.
64
     *
65
     * @param  array|string $query
66
     * @param  array $columns
67
     * @param  boolean $fulltext
68
     * @param  float $threshold
69
     * @return $this
70
     */
71
    public function search($query, $columns = null, $fulltext = true, $threshold = null)
72
    {
73
        if (is_bool($columns)) {
28×
74
            list($fulltext, $columns) = [$columns, []];
2×
75
        }
only 204.1 - 1×
76

77
        $parser = static::$parser->make();
28×
78

79
        $words = is_array($query) ? $query : $parser->parseQuery($query, $fulltext);
28×
80

81
        $columns = $parser->parseWeights($columns ?: $this->model->getSearchableColumns());
28×
82

83
        if (count($words) && count($columns)) {
28×
84
            $this->query->from($this->buildSubquery($words, $columns, $threshold));
26×
85
        }
only 204.1 - 13×
86

87
        return $this;
28×
88
    }
89

90
    /**
91
     * Build the search subquery.
92
     *
93
     * @param  array $words
94
     * @param  array $mappings
95
     * @param  float $threshold
96
     * @return \Sofa\Eloquence\Searchable\Subquery
97
     */
98
    protected function buildSubquery(array $words, array $mappings, $threshold)
99
    {
100
        $subquery = new SearchableSubquery($this->query->newQuery(), $this->model->getTable());
26×
101

102
        $columns = $this->joinForSearch($mappings, $subquery);
26×
103

104
        $threshold = (is_null($threshold))
26×
105
                        ? array_sum($columns->getWeights()) / 4
26×
106
                        : (float) $threshold;
26×
107

108
        $subquery->select($this->model->getTable() . '.*')
26×
109
                 ->from($this->model->getTable())
26×
110
                 ->groupBy($this->model->getQualifiedKeyName());
26×
111

112
        $this->addSearchClauses($subquery, $columns, $words, $threshold);
26×
113

114
        return $subquery;
26×
115
    }
116

117
    /**
118
     * Add select and where clauses on the subquery.
119
     *
120
     * @param  \Sofa\Eloquence\Searchable\Subquery $subquery
121
     * @param  \Sofa\Eloquence\Searchable\ColumnCollection $columns
122
     * @param  array $words
123
     * @param  float $threshold
124
     * @return void
125
     */
126
    protected function addSearchClauses(
127
        SearchableSubquery $subquery,
128
        ColumnCollection $columns,
129
        array $words,
130
        $threshold
131
    ) {
132
        $whereBindings = $this->searchSelect($subquery, $columns, $words, $threshold);
26×
133

134
        // For morphOne/morphMany support we need to port the bindings from JoinClauses.
135
        $joinBindings = collect($subquery->getQuery()->joins)->flatMap(function ($join) {
136
            return $join->getBindings();
6×
137
        })->all();
26×
138

139
        $this->addBinding($joinBindings, 'select');
26×
140

141
        // Developer may want to skip the score threshold filtering by passing zero
142
        // value as threshold in order to simply order full result by relevance.
143
        // Otherwise we are going to add where clauses for speed improvement.
144
        if ($threshold > 0) {
26×
145
            $this->searchWhere($subquery, $columns, $words, $whereBindings);
26×
146
        }
only 204.1 - 13×
147

148
        $this->query->where('relevance', '>=', new Expression($threshold));
26×
149

150
        $this->query->orders = array_merge(
26×
151
            [['column' => 'relevance', 'direction' => 'desc']],
26×
152
            (array) $this->query->orders
26×
153
        );
only 204.1 - 13×
154
    }
26×
155

156
    /**
157
     * Apply relevance select on the subquery.
158
     *
159
     * @param  \Sofa\Eloquence\Searchable\Subquery $subquery
160
     * @param  \Sofa\Eloquence\Searchable\ColumnCollection $columns
161
     * @param  array $words
162
     * @return array
163
     */
164
    protected function searchSelect(SearchableSubquery $subquery, ColumnCollection $columns, array $words)
165
    {
166
        $cases = $bindings = [];
26×
167

168
        foreach ($columns as $column) {
26×
169
            list($cases[], $binding) = $this->buildCase($column, $words);
26×
170

171
            $bindings = array_merge_recursive($bindings, $binding);
26×
172
        }
only 204.1 - 13×
173

174
        $select = implode(' + ', $cases);
26×
175

176
        $subquery->selectRaw("max({$select}) as relevance");
26×
177

178
        $this->addBinding($bindings['select'], 'select');
26×
179

180
        return $bindings['where'];
26×
181
    }
182

183
    /**
184
     * Apply where clauses on the subquery.
185
     *
186
     * @param  \Sofa\Eloquence\Searchable\Subquery $subquery
187
     * @param  \Sofa\Eloquence\Searchable\ColumnCollection $columns
188
     * @param  array $words
189
     * @return void
190
     */
191
    protected function searchWhere(
192
        SearchableSubquery $subquery,
193
        ColumnCollection $columns,
194
        array $words,
195
        array $bindings
196
    ) {
197
        $operator = $this->getLikeOperator();
26×
198

199
        $wheres = [];
26×
200

201
        foreach ($columns as $column) {
26×
202
            $wheres[] = implode(
26×
203
                ' or ',
26×
204
                array_fill(0, count($words), sprintf('%s %s ?', $column->getWrapped(), $operator))
26×
205
            );
only 204.1 - 13×
206
        }
only 204.1 - 13×
207

208
        $where = implode(' or ', $wheres);
26×
209

210
        $subquery->whereRaw("({$where})");
26×
211

212
        $this->addBinding($bindings, 'select');
26×
213
    }
26×
214

215
    /**
216
     * Move where clauses to subquery to improve performance.
217
     *
218
     * @param  \Sofa\Eloquence\Searchable\Subquery $subquery
219
     * @return void
220
     */
221
    protected function wheresToSubquery(SearchableSubquery $subquery)
222
    {
223
        $bindingKey = 0;
4×
224

225
        $typesToMove = [
226
            'basic', 'in', 'notin', 'between', 'null',
4×
227
            'notnull', 'date', 'day', 'month', 'year',
only 204.1 - 2×
228
        ];
only 204.1 - 2×
229

230
        // Here we are going to move all the where clauses that we might apply
231
        // on the subquery in order to improve performance, since this way
232
        // we can drastically reduce number of joined rows on subquery.
233
        foreach ((array) $this->query->wheres as $key => $where) {
4×
234
            $type = strtolower($where['type']);
4×
235

236
            $bindingsCount = $this->countBindings($where, $type);
4×
237

238
            if (in_array($type, $typesToMove) && $this->model->hasColumn($where['column'])) {
4×
239
                unset($this->query->wheres[$key]);
4×
240

241
                $where['column'] = $this->model->getTable() . '.' . $where['column'];
4×
242

243
                $subquery->getQuery()->wheres[] = $where;
4×
244

245
                $whereBindings = $this->query->getRawBindings()['where'];
4×
246

247
                $bindings = array_splice($whereBindings, $bindingKey, $bindingsCount);
4×
248

249
                $this->query->setBindings($whereBindings, 'where');
4×
250

251
                $this->query->addBinding($bindings, 'select');
4×
252

253
            // if where is not to be moved onto the subquery, let's increment
254
            // binding key appropriately, so we can reliably move binding
255
            // for the next where clauses in the loop that is running.
256
            } else {
only 204.1 - 2×
257
                $bindingKey += $bindingsCount;
4×
258
            }
259
        }
only 204.1 - 2×
260
    }
4×
261

262
    /**
263
     * Get number of bindings provided for a where clause.
264
     *
265
     * @param  array   $where
266
     * @param  string  $type
267
     * @return integer
268
     */
269
    protected function countBindings(array $where, $type)
270
    {
271
        if ($this->isHasWhere($where, $type)) {
4×
272
            return substr_count($where['column'] . $where['value'], '?');
!
273
        } elseif ($type === 'basic') {
4×
274
            return (int) !$where['value'] instanceof Expression;
4×
275
        } elseif (in_array($type, ['basic', 'date', 'year', 'month', 'day'])) {
4×
276
            return (int) !$where['value'] instanceof Expression;
2×
277
        } elseif (in_array($type, ['null', 'notnull'])) {
4×
278
            return 0;
2×
279
        } elseif ($type === 'between') {
4×
280
            return 2;
2×
281
        } elseif (in_array($type, ['in', 'notin'])) {
4×
282
            return count($where['values']);
2×
283
        } elseif ($type === 'raw') {
4×
284
            return substr_count($where['sql'], '?');
2×
285
        } elseif (in_array($type, ['nested', 'sub', 'exists', 'notexists', 'insub', 'notinsub'])) {
4×
286
            return count($where['query']->getBindings());
4×
287
        }
288
    }
!
289

290
    /**
291
     * Determine whether where clause is eloquent has subquery.
292
     *
293
     * @param  array  $where
294
     * @param  string $type
295
     * @return boolean
296
     */
297
    protected function isHasWhere($where, $type)
298
    {
299
        return $type === 'basic'
only 204.3 - 2×
300
                && $where['column'] instanceof Expression
4×
301
                && $where['value'] instanceof Expression;
4×
302
    }
303

304
    /**
305
     * Build case clause from all words for a single column.
306
     *
307
     * @param  \Sofa\Eloquence\Searchable\Column $column
308
     * @param  array  $words
309
     * @return array
310
     */
311
    protected function buildCase(Column $column, array $words)
312
    {
313
        // THIS IS BAD
314
        // @todo refactor
315

316
        $operator = $this->getLikeOperator();
26×
317

318
        $bindings['select'] = $bindings['where'] = array_map(function ($word) {
319
            return $this->caseBinding($word);
26×
320
        }, $words);
26×
321

322
        $case = $this->buildEqualsCase($column, $words);
26×
323

324
        if (strpos(implode('', $words), '*') !== false) {
26×
325
            $leftMatching = [];
12×
326

327
            foreach ($words as $key => $word) {
12×
328
                if ($this->isLeftMatching($word)) {
12×
329
                    $leftMatching[] = sprintf('%s %s ?', $column->getWrapped(), $operator);
12×
330
                    $bindings['select'][] = $bindings['where'][$key] = $this->caseBinding($word) . '%';
12×
331
                }
only 204.1 - 6×
332
            }
only 204.1 - 6×
333

334
            if (count($leftMatching)) {
12×
335
                $leftMatching = implode(' or ', $leftMatching);
12×
336
                $score = 5 * $column->getWeight();
12×
337
                $case .= " + case when {$leftMatching} then {$score} else 0 end";
12×
338
            }
only 204.1 - 6×
339

340
            $wildcards = [];
12×
341

342
            foreach ($words as $key => $word) {
12×
343
                if ($this->isWildcard($word)) {
12×
344
                    $wildcards[] = sprintf('%s %s ?', $column->getWrapped(), $operator);
8×
345
                    $bindings['select'][] = $bindings['where'][$key] = '%'.$this->caseBinding($word) . '%';
8×
346
                }
only 204.1 - 4×
347
            }
only 204.1 - 6×
348

349
            if (count($wildcards)) {
12×
350
                $wildcards = implode(' or ', $wildcards);
8×
351
                $score = 1 * $column->getWeight();
8×
352
                $case .= " + case when {$wildcards} then {$score} else 0 end";
8×
353
            }
only 204.1 - 4×
354
        }
only 204.1 - 6×
355

356
        return [$case, $bindings];
26×
357
    }
358

359
    /**
360
     * Replace '?' with single character SQL wildcards.
361
     *
362
     * @param  string $word
363
     * @return string
364
     */
365
    protected function caseBinding($word)
366
    {
367
        $parser = static::$parser->make();
26×
368

369
        return str_replace('?', '_', $parser->stripWildcards($word));
26×
370
    }
371

372
    /**
373
     * Build basic search case for 'equals' comparison.
374
     *
375
     * @param  \Sofa\Eloquence\Searchable\Column $column
376
     * @param  array  $words
377
     * @return string
378
     */
379
    protected function buildEqualsCase(Column $column, array $words)
380
    {
381
        $equals = implode(' or ', array_fill(0, count($words), sprintf('%s = ?', $column->getWrapped())));
26×
382

383
        $score = 15 * $column->getWeight();
26×
384

385
        return "case when {$equals} then {$score} else 0 end";
26×
386
    }
387

388
    /**
389
     * Determine whether word ends with wildcard.
390
     *
391
     * @param  string  $word
392
     * @return boolean
393
     */
394
    protected function isLeftMatching($word)
395
    {
396
        return ends_with($word, '*');
12×
397
    }
398

399
    /**
400
     * Determine whether word starts and ends with wildcards.
401
     *
402
     * @param  string  $word
403
     * @return boolean
404
     */
405
    protected function isWildcard($word)
406
    {
407
        return ends_with($word, '*') && starts_with($word, '*');
12×
408
    }
409

410
    /**
411
     * Get driver-specific case insensitive like operator.
412
     *
413
     * @return string
414
     */
415
    public function getLikeOperator()
416
    {
417
        $grammar = $this->query->getGrammar();
26×
418

419
        if ($grammar instanceof PostgresGrammar) {
26×
420
            return 'ilike';
2×
421
        }
422

423
        return 'like';
24×
424
    }
425

426
    /**
427
     * Join related tables on the search subquery.
428
     *
429
     * @param  array $mappings
430
     * @param  \Sofa\Eloquence\Searchable\Subquery $subquery
431
     * @return \Sofa\Eloquence\Searchable\ColumnCollection
432
     */
433
    protected function joinForSearch($mappings, $subquery)
434
    {
435
        $mappings = is_array($mappings) ? $mappings : (array) $mappings;
26×
436

437
        $columns = new ColumnCollection;
26×
438

439
        $grammar = $this->query->getGrammar();
26×
440

441
        $joiner = static::$joinerFactory->make($subquery->getQuery(), $this->model);
26×
442

443
        // Here we loop through the search mappings in order to join related tables
444
        // appropriately and build a searchable column collection, which we will
445
        // use to build select and where clauses with correct table prefixes.
446
        foreach ($mappings as $mapping => $weight) {
26×
447
            if (strpos($mapping, '.') !== false) {
26×
448
                list($relation, $column) = $this->model->parseMappedColumn($mapping);
6×
449

450
                $related = $joiner->leftJoin($relation);
6×
451

452
                $columns->add(
6×
453
                    new Column($grammar, $related->getTable(), $column, $mapping, $weight)
6×
454
                );
only 204.1 - 3×
455
            } else {
only 204.1 - 3×
456
                $columns->add(
26×
457
                    new Column($grammar, $this->model->getTable(), $mapping, $mapping, $weight)
26×
458
                );
only 204.1 - 13×
459
            }
460
        }
only 204.1 - 13×
461

462
        return $columns;
26×
463
    }
464

465
    /**
466
     * Prefix selected columns with table name in order to avoid collisions.
467
     *
468
     * @return $this
469
     */
470
    public function prefixColumnsForJoin()
471
    {
472
        if (!$columns = $this->query->columns) {
52×
473
            return $this->select($this->model->getTable() . '.*');
50×
474
        }
475

476
        foreach ($columns as $key => $column) {
8×
477
            if ($this->model->hasColumn($column)) {
8×
UNCOV
478
                $columns[$key] = $this->model->getTable() . '.' . $column;
!
479
            }
!
480
        }
only 204.1 - 4×
481

482
        $this->query->columns = $columns;
8×
483

484
        return $this;
8×
485
    }
486

487
    /**
488
     * Join related tables.
489
     *
490
     * @param  array|string $relations
491
     * @param  string $type
492
     * @return $this
493
     */
494
    public function joinRelations($relations, $type = 'inner')
495
    {
496
        if (is_null($this->joiner)) {
2×
497
            $this->joiner = static::$joinerFactory->make($this);
2×
498
        }
only 204.1 - 1×
499

500
        if (!is_array($relations)) {
2×
501
            list($relations, $type) = [func_get_args(), 'inner'];
2×
502
        }
only 204.1 - 1×
503

504
        foreach ($relations as $relation) {
2×
505
            $this->joiner->join($relation, $type);
2×
506
        }
only 204.1 - 1×
507

508
        return $this;
2×
509
    }
510

511
    /**
512
     * Left join related tables.
513
     *
514
     * @param  array|string $relations
515
     * @return $this
516
     */
517
    public function leftJoinRelations($relations)
518
    {
519
        $relations = is_array($relations) ? $relations : func_get_args();
2×
520

521
        return $this->joinRelations($relations, 'left');
2×
522
    }
523

524
    /**
525
     * Right join related tables.
526
     *
527
     * @param  array|string $relations
528
     * @return $this
529
     */
530
    public function rightJoinRelations($relations)
531
    {
532
        $relations = is_array($relations) ? $relations : func_get_args();
2×
533

534
        return $this->joinRelations($relations, 'right');
2×
535
    }
536

537
    /**
538
     * Set search query parser factory instance.
539
     *
540
     * @param \Sofa\Eloquence\Contracts\Searchable\ParserFactory $factory
541
     */
542
    public static function setParserFactory(ParserFactory $factory)
543
    {
544
        static::$parser = $factory;
34×
545
    }
34×
546

547
    /**
548
     * Set the relations joiner factory instance.
549
     *
550
     * @param \Sofa\Eloquence\Contracts\Relations\JoinerFactory $factory
551
     */
552
    public static function setJoinerFactory(JoinerFactory $factory)
553
    {
554
        static::$joinerFactory = $factory;
34×
555
    }
34×
556
}
Troubleshooting · Open an Issue · Sales · Support · ENTERPRISE · CAREERS · STATUS
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2023 Coveralls, Inc