Passed
Push — master ( bef460...f63084 )
by Michael
02:34
created

RelatedPlusTrait::scopeOrderByCheckModel()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 13
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 7
nc 3
nop 3
1
<?php
2
3
namespace Blasttech\EloquentRelatedPlus;
4
5
use Illuminate\Database\Eloquent\Builder;
6
use Illuminate\Database\Eloquent\Model;
7
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8
use Illuminate\Database\Eloquent\Relations\HasMany;
9
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
10
use Illuminate\Database\Eloquent\Relations\Relation;
11
use Illuminate\Database\Query\Expression;
12
use Illuminate\Database\Query\JoinClause;
13
use Illuminate\Support\Facades\DB;
14
use Illuminate\Support\Facades\Schema;
15
use InvalidArgumentException;
16
17
/**
18
 * Trait RelatedPlusTrait
19
 *
20
 * @property array order_fields
21
 * @property array order_defaults
22
 * @property array order_relations
23
 * @property array order_with
24
 * @property array search_fields
25
 * @property string connection
26
 *
27
 * @package Blasttech\WherePlus
28
 */
29
trait RelatedPlusTrait
30
{
31
    use HelperMethodTrait;
32
33
    /**
34
     * Get the table associated with the model.
35
     *
36
     * @return string
37
     */
38
    abstract function getTable();
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
39
40
    /**
41
     * Boot method for trait
42
     *
43
     */
44
    public static function bootRelatedPlusTrait()
45
    {
46
        static::saving(function ($model) {
47
            if (!empty($model->nullable)) {
48
                foreach ($model->attributes as $key => $value) {
49
                    if (isset($model->nullable[$key])) {
50
                        $model->{$key} = empty(trim($value)) ? null : $value;
51
                    }
52
                }
53
            }
54
        });
55
    }
56
57
    /**
58
     * Add joins for one or more relations
59
     * This determines the foreign key relations automatically to prevent the need to figure out the columns.
60
     * Usages:
61
     * $query->modelJoin('customers')
62
     * $query->modelJoin('customer.client')
63
     *
64
     * @param Builder $query
65
     * @param string $relationName
66
     * @param string $operator
67
     * @param string $type
68
     * @param bool $where
69
     * @param bool $relatedSelect
70
     * @param string|null $direction
71
     *
72
     * @return Builder
73
     */
74
    public function scopeModelJoin(
75
        Builder $query,
76
        $relationName,
77
        $operator = '=',
78
        $type = 'left',
79
        $where = false,
80
        $relatedSelect = true,
81
        $direction = null
82
    ) {
83
        $connection = $this->connection;
84
85
        foreach ($this->parseRelationNames($relationName) as $relation) {
86
            $tableName = $relation->getRelated()->getTable();
87
            // if using a 'table' AS 'tableAlias' in a from statement, otherwise alias will be the table name
88
            $from = explode(' ', $relation->getQuery()->getQuery()->from);
89
            $tableAlias = array_pop($from);
90
91
            if (empty($query->getQuery()->columns)) {
92
                $query->select($this->getTable() . ".*");
0 ignored issues
show
Bug introduced by
The method select() does not exist on Illuminate\Database\Eloquent\Builder. Did you maybe mean createSelectWithConstraint()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
93
            }
94
            if ($relatedSelect) {
95
                foreach (Schema::connection($connection)->getColumnListing($tableName) as $relatedColumn) {
96
                    $query->addSelect(
97
                        new Expression("`$tableAlias`.`$relatedColumn` AS `$tableAlias.$relatedColumn`")
98
                    );
99
                }
100
            }
101
            $query->relationJoin($tableName, $tableAlias, $relation, $operator, $type, $where, $direction);
102
        }
103
104
        return $query;
105
    }
106
107
    /**
108
     * Join a model
109
     *
110
     * @param Builder $query
111
     * @param string $tableName
112
     * @param string $tableAlias
113
     * @param Relation $relation
114
     * @param string $operator
115
     * @param string $type
116
     * @param boolean $where
117
     * @param null $direction
118
     * @return Builder
119
     */
120
    public function scopeRelationJoin(
121
        Builder $query,
122
        $tableName,
123
        $tableAlias,
124
        $relation,
125
        $operator,
126
        $type,
127
        $where,
128
        $direction = null
129
    ) {
130
        if ($tableAlias !== '' && $tableName !== $tableAlias) {
131
            $fullTableName = $tableName . ' AS ' . $tableAlias;
132
        } else {
133
            $fullTableName = $tableName;
134
        }
135
136
        return $query->join($fullTableName, function (JoinClause $join) use (
137
            $tableName,
138
            $tableAlias,
139
            $relation,
140
            $operator,
141
            $direction
142
        ) {
143
            // If a HasOne relation and ordered - ie join to the latest/earliest
144
            if (class_basename($relation) === 'HasOne' && !empty($relation->toBase()->orders)) {
145
                return $this->hasOneJoin($relation, $join);
146
            } else {
147
                return $this->hasManyJoin($relation, $join, $tableName, $tableAlias, $operator, $direction);
148
            }
149
        }, null, null, $type, $where);
150
    }
151
152
    /**
153
     * Join a HasOne relation which is ordered
154
     *
155
     * @param Relation $relation
156
     * @param JoinClause $join
157
     * @return JoinClause
158
     */
159
    private function hasOneJoin($relation, $join)
160
    {
161
        // Get first relation order (should only be one)
162
        $order = $relation->toBase()->orders[0];
163
164
        return $join->on($order['column'], $this->hasOneJoinSql($relation, $order));
165
    }
166
167
    /**
168
     * Get join sql for a HasOne relation
169
     *
170
     * @param Relation $relation
171
     * @param array $order
172
     * @return Expression
173
     */
174
    public function hasOneJoinSql($relation, $order)
175
    {
176
        // Build subquery for getting first/last record in related table
177
        $subQuery = $this
178
            ->joinOne(
179
                $relation->getRelated()->newQuery(),
180
                $relation,
181
                $order['column'],
182
                $order['direction']
183
            )
184
            ->setBindings($relation->getBindings());
185
186
        return DB::raw('(' . $this->toSqlWithBindings($subQuery) . ')');
187
    }
188
189
    /**
190
     * Adds a where for a relation's join columns and and min/max for a given column
191
     *
192
     * @param Builder $query
193
     * @param Relation $relation
194
     * @param string $column
195
     * @param string $direction
196
     * @return Builder
197
     */
198
    public function joinOne($query, $relation, $column, $direction)
199
    {
200
        // Get join fields
201
        $joinColumns = $this->getJoinColumns($relation);
202
203
        return $this->selectMinMax(
204
            $query->whereColumn($joinColumns->first, '=', $joinColumns->second),
205
            $column,
206
            $direction
207
        );
208
    }
209
210
    /**
211
     * Get the join columns for a relation
212
     *
213
     * @param Relation|BelongsTo|HasOneOrMany $relation
214
     * @return \stdClass
215
     */
216
    protected function getJoinColumns($relation)
217
    {
218
        // Get keys with table names
219
        if ($relation instanceof BelongsTo) {
220
            $first = $relation->getOwnerKey();
221
            $second = $relation->getForeignKey();
222
        } else {
223
            $first = $relation->getQualifiedParentKeyName();
224
            $second = $relation->getQualifiedForeignKeyName();
225
        }
226
227
        return (object)['first' => $first, 'second' => $second];
228
    }
229
230
    /**
231
     * Adds a select for a min or max on the given column, depending on direction given
232
     *
233
     * @param Builder $query
234
     * @param string $column
235
     * @param string $direction
236
     * @return Builder
237
     */
238
    public function selectMinMax($query, $column, $direction)
239
    {
240
        $column = $this->addBackticks($column);
241
242
        /** @var Model $query */
243
        if ($direction == 'asc') {
244
            return $query->select(DB::raw('MIN(' . $column . ')'));
245
        } else {
246
            return $query->select(DB::raw('MAX(' . $column . ')'));
247
        }
248
    }
249
250
    /**
251
     * Join a HasMany Relation
252
     *
253
     * @param Relation $relation
254
     * @param JoinClause $join
255
     * @param string $tableName
256
     * @param string $tableAlias
257
     * @param string $operator
258
     * @param string $direction
259
     * @return Builder|JoinClause
260
     */
261
    private function hasManyJoin($relation, $join, $tableName, $tableAlias, $operator, $direction)
262
    {
263
        // Get relation join columns
264
        $joinColumns = $this->getJoinColumns($relation);
265
266
        $first = $joinColumns->first;
267
        $second = $joinColumns->second;
268
        if ($tableName !== $tableAlias) {
269
            $first = str_replace($tableName, $tableAlias, $first);
270
            $second = str_replace($tableName, $tableAlias, $second);
271
        }
272
273
        $join->on($first, $operator, $second);
274
275
        // Add any where clauses from the relationship
276
        $join = $this->addWhereConstraints($join, $relation, $tableAlias);
277
278
        if (!is_null($direction) && get_class($relation) === HasMany::class) {
279
            $join = $this->hasManyJoinWhere($join, $first, $relation, $tableAlias, $direction);
0 ignored issues
show
Bug introduced by
It seems like $join can also be of type object<Illuminate\Database\Eloquent\Builder>; however, Blasttech\EloquentRelate...ait::hasManyJoinWhere() does only seem to accept object<Illuminate\Database\Query\JoinClause>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
280
        }
281
282
        return $join;
283
    }
284
285
    /**
286
     * Add wheres if they exist for a relation
287
     *
288
     * @param Builder|JoinClause $builder
289
     * @param Relation|BelongsTo|HasOneOrMany $relation
290
     * @param string $table
291
     * @return Builder|JoinClause
292
     */
293
    protected function addWhereConstraints($builder, $relation, $table)
294
    {
295
        // Get where clauses from the relationship
296
        $wheres = collect($relation->toBase()->wheres)
297
            ->where('type', 'Basic')
298
            ->map(function ($where) use ($table) {
299
                // Add table name to column if it is absent
300
                return [$this->columnWithTableName($table, $where['column']), $where['operator'], $where['value']];
301
            })->toArray();
302
303
        if (!empty($wheres)) {
304
            $builder->where($wheres);
305
        }
306
307
        return $builder;
308
    }
309
310
    /**
311
     * If the relation is one-to-many, just get the first related record
312
     *
313
     * @param JoinClause $joinClause
314
     * @param string $column
315
     * @param HasMany|Relation $relation
316
     * @param string $table
317
     * @param string $direction
318
     *
319
     * @return JoinClause
320
     */
321
    public function hasManyJoinWhere(JoinClause $joinClause, $column, $relation, $table, $direction)
322
    {
323
        return $joinClause->where(
324
            $column,
325
            function ($subQuery) use ($table, $direction, $relation, $column) {
326
                $subQuery = $this->joinOne(
327
                    $subQuery->from($table),
328
                    $relation,
329
                    $column,
330
                    $direction
331
                );
332
333
                // Add any where statements with the relationship
334
                $subQuery = $this->addWhereConstraints($subQuery, $relation, $table);
335
336
                // Add any order statements with the relationship
337
                return $this->addOrder($subQuery, $relation, $table);
338
            }
339
        );
340
    }
341
342
    /**
343
     * Add orderBy if orders exist for a relation
344
     *
345
     * @param Builder|JoinClause $builder
346
     * @param Relation|BelongsTo|HasOneOrMany $relation
347
     * @param string $table
348
     * @return Builder
349
     */
350
    protected function addOrder($builder, $relation, $table)
351
    {
352
        if (!empty($relation->toBase()->orders)) {
353
            // Get where clauses from the relationship
354
            foreach ($relation->toBase()->orders as $order) {
355
                $builder->orderBy($this->columnWithTableName($table, $order['column']), $order['direction']);
0 ignored issues
show
Bug introduced by
The method orderBy does only exist in Illuminate\Database\Query\JoinClause, but not in Illuminate\Database\Eloquent\Builder.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
356
            }
357
        }
358
359
        return $builder;
360
    }
361
362
    /**
363
     * Set the order of a model
364
     *
365
     * @param Builder $query
366
     * @param string $orderField
367
     * @param string $direction
368
     * @return Builder
369
     */
370
    public function scopeOrderByCustom(Builder $query, $orderField, $direction)
371
    {
372
        if ($this->hasFieldsAndDefaults($orderField, $direction)) {
373
            $query = $this->removeGlobalScope($query, 'order');
374
        }
375
376
        return $query->setCustomOrder($orderField, $direction);
377
    }
378
379
    /**
380
     * Check $order_fields and $order_defaults are set
381
     *
382
     * @param string $orderField
383
     * @param string $direction
384
     * @return bool
385
     */
386
    private function hasFieldsAndDefaults($orderField, $direction)
387
    {
388
        if (!isset($this->order_fields) || !is_array($this->order_fields)) {
389
            throw new InvalidArgumentException(get_class($this) . ' order fields not set correctly.');
390
        } else {
391
            if (($orderField === '' || $direction === '')
392
                && (!isset($this->order_defaults) || !is_array($this->order_defaults))) {
393
                throw new InvalidArgumentException(get_class($this) . ' order defaults not set and not overriden.');
394
            } else {
395
                return true;
396
            }
397
        }
398
    }
399
400
    /**
401
     * Check if column being sorted by is from a related model
402
     *
403
     * @param Builder $query
404
     * @param string $column
405
     * @param string $direction
406
     * @return Builder
407
     */
408
    public function scopeOrderByCheckModel(Builder $query, $column, $direction)
409
    {
410
        /** @var Model $query */
411
        $query->orderBy(DB::raw($column), $direction);
412
413
        $periodPos = strpos($column, '.');
414
        if (isset($this->order_relations) && ($periodPos !== false || isset($this->order_relations[$column]))) {
415
            $table = ($periodPos !== false ? substr($column, 0, $periodPos) : $column);
416
            $query = $this->joinRelatedTable($query, $table);
417
        }
418
419
        return $query;
420
    }
421
422
    /**
423
     * Join a related table if not already joined
424
     *
425
     * @param Builder $query
426
     * @param string $table
427
     * @return Builder
428
     */
429
    private function joinRelatedTable($query, $table)
430
    {
431
        if (isset($this->order_relations[$table]) &&
432
            !$this->hasJoin($query, $table, $this->order_relations[$table])) {
433
            $columnRelations = $this->order_relations[$table];
434
435
            $query->modelJoin(
436
                $columnRelations,
437
                '=',
438
                'left',
439
                false,
440
                false
441
            );
442
        }
443
444
        return $query;
445
    }
446
447
    /**
448
     * Set the model order
449
     *
450
     * @param Builder $query
451
     * @param string $column
452
     * @param string $direction
453
     * @return Builder
454
     */
455
    public function scopeSetCustomOrder(Builder $query, $column, $direction)
456
    {
457
        if (isset($this->order_defaults)) {
458
            $column = $this->setColumn($column);
459
            $direction = $this->setDirection($direction);
460
        }
461
462
        return $this->setOrder($query, $column, $direction);
463
    }
464
465
    /**
466
     * Override column if provided column not valid
467
     *
468
     * @param string $column
469
     * @return string
470
     */
471
    private function setColumn($column)
472
    {
473
        // If $column not in order_fields list, use default
474
        if ($column == '' || !isset($this->order_fields[$column])) {
475
            $column = $this->order_defaults['field'];
476
        }
477
478
        return $column;
479
    }
480
481
    /**
482
     * Override direction if provided direction not valid
483
     *
484
     * @param string $direction
485
     * @return string
486
     */
487
    private function setDirection($direction)
488
    {
489
        // If $direction not asc or desc, use default
490
        if ($direction == '' || !in_array(strtoupper($direction), ['ASC', 'DESC'])) {
491
            $direction = $this->order_defaults['dir'];
492
        }
493
494
        return $direction;
495
    }
496
497
    /**
498
     * Set order based on order_fields
499
     *
500
     * @param Builder $query
501
     * @param string $column
502
     * @param string $direction
503
     * @return Builder
504
     */
505
    private function setOrder($query, $column, $direction)
506
    {
507
        if (!is_array($this->order_fields[$column])) {
508
            $query->orderByCheckModel($this->order_fields[$column], $direction);
509
        } else {
510
            foreach ($this->order_fields[$column] as $dbField) {
511
                $query->orderByCheckModel($dbField, $direction);
512
            }
513
        }
514
515
        return $query;
516
    }
517
518
    /**
519
     * Switch a query to be a subquery of a model
520
     *
521
     * @param Builder $query
522
     * @param Builder $model
523
     * @return Builder
524
     */
525
    public function scopeSetSubquery(Builder $query, $model)
526
    {
527
        $sql = $this->toSqlWithBindings($model);
528
        $table = $model->getQuery()->from;
529
530
        return $query
531
            ->from(DB::raw("({$sql}) as " . $table))
532
            ->select($table . '.*');
533
    }
534
535
    /**
536
     * Use a model method to add columns or joins if in the order options
537
     *
538
     * @param Builder $query
539
     * @param string $order
540
     * @return Builder
541
     */
542
    public function scopeOrderByWith(Builder $query, $order)
543
    {
544
        if (isset($this->order_with[$order])) {
545
            $with = 'with' . $this->order_with[$order];
546
547
            $query->$with();
548
        }
549
550
        if (isset($this->order_fields[$order])) {
551
            $orderOption = (explode('.', $this->order_fields[$order]))[0];
552
553
            if (isset($this->order_relations[$orderOption])) {
554
                $query->modelJoin(
555
                    $this->order_relations[$orderOption],
556
                    '=',
557
                    'left',
558
                    false,
559
                    false
560
                );
561
            }
562
        }
563
564
        return $query;
565
    }
566
567
    /**
568
     * Add where statements for the model search fields
569
     *
570
     * @param Builder $query
571
     * @param string $searchText
572
     * @return Builder
573
     */
574
    public function scopeSearch(Builder $query, $searchText = '')
575
    {
576
        $searchText = trim($searchText);
577
578
        // If search is set
579
        if ($searchText != "") {
580
            if (!isset($this->search_fields) || !is_array($this->search_fields) || empty($this->search_fields)) {
581
                throw new InvalidArgumentException(get_class($this) . ' search properties not set correctly.');
582
            } else {
583
                $query = $this->checkSearchFields($query, $searchText);
584
            }
585
        }
586
587
        return $query;
588
    }
589
590
    /**
591
     * Add where statements for search fields to search for searchText
592
     *
593
     * @param Builder $query
594
     * @param string $searchText
595
     * @return Builder
596
     */
597
    private function checkSearchFields($query, $searchText)
598
    {
599
        return $query->where(function (Builder $query) use ($searchText) {
600
            if (isset($this->search_fields) && !empty($this->search_fields)) {
601
                /** @var Model $this */
602
                $table = $this->getTable();
603
                foreach ($this->search_fields as $searchField => $searchFieldParameters) {
604
                    $query = $this->checkSearchField($query, $table, $searchField, $searchFieldParameters, $searchText);
605
                }
606
            }
607
608
            return $query;
609
        });
610
    }
611
612
    /**
613
     * Add where statement for a search field
614
     *
615
     * @param Builder $query
616
     * @param string $table
617
     * @param string $searchField
618
     * @param array $searchFieldParameters
619
     * @param string $searchText
620
     * @return Builder
621
     */
622
    private function checkSearchField($query, $table, $searchField, $searchFieldParameters, $searchText)
623
    {
624
        if (!isset($searchFieldParameters['regex']) || preg_match($searchFieldParameters['regex'], $searchText)) {
625
            $searchColumn = is_array($searchFieldParameters) ? $searchField : $searchFieldParameters;
626
627
            if (isset($searchFieldParameters['relation'])) {
628
                return $this->searchRelation($query, $searchFieldParameters, $searchColumn, $searchText);
629
            } else {
630
                return $this->searchThis($query, $searchFieldParameters, $table, $searchColumn, $searchText);
631
            }
632
        } else {
633
            return $query;
634
        }
635
    }
636
637
    /**
638
     * Add where condition to search a relation
639
     *
640
     * @param Builder $query
641
     * @param array $searchFieldParameters
642
     * @param string $searchColumn
643
     * @param string $searchText
644
     * @return Builder
645
     */
646
    private function searchRelation(Builder $query, $searchFieldParameters, $searchColumn, $searchText)
647
    {
648
        $relation = $searchFieldParameters['relation'];
649
        $relatedTable = $this->$relation()->getRelated()->getTable();
650
651
        return $query->orWhere(function (Builder $query) use (
652
            $searchText,
653
            $searchColumn,
654
            $searchFieldParameters,
655
            $relation,
656
            $relatedTable
657
        ) {
658
            return $query->orWhereHas($relation, function (Builder $query2) use (
659
                $searchText,
660
                $searchColumn,
661
                $searchFieldParameters,
662
                $relatedTable
663
            ) {
664
                return $query2->where($relatedTable . '.' . $searchColumn, 'like', $searchText . '%');
665
            });
666
        });
667
    }
668
669
    /**
670
     * Add where condition to search current model
671
     *
672
     * @param Builder $query
673
     * @param array $searchFieldParameters
674
     * @param string $table
675
     * @param string $searchColumn
676
     * @param string $searchText
677
     * @return Builder
678
     */
679
    public function searchThis(Builder $query, $searchFieldParameters, $table, $searchColumn, $searchText)
680
    {
681
        $searchOperator = $searchFieldParameters['operator'] ?? 'like';
682
        $searchValue = $searchFieldParameters['value'] ?? '%{{search}}%';
683
684
        return $query->orWhere(
685
            $table . '.' . $searchColumn,
686
            $searchOperator,
687
            str_replace('{{search}}', $searchText, $searchValue)
688
        );
689
    }
690
}
691