Completed
Push — master ( f63084...bb5576 )
by Michael
05:17
created

RelatedPlusTrait::joinRelatedTable()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 11
nc 2
nop 2
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
trait RelatedPlusTrait
28
{
29
    use CustomOrderTrait, HelperMethodTrait;
30
31
    /**
32
     * Get the table associated with the model.
33
     *
34
     * @return string
35
     */
36
    abstract public function getTable();
37
38
    /**
39
     * Boot method for trait
40
     *
41
     */
42
    public static function bootRelatedPlusTrait()
43
    {
44
        static::saving(function ($model) {
45
            if (!empty($model->nullable)) {
46
                foreach ($model->attributes as $key => $value) {
47
                    if (isset($model->nullable[$key])) {
48
                        $model->{$key} = empty(trim($value)) ? null : $value;
49
                    }
50
                }
51
            }
52
        });
53
    }
54
55
    /**
56
     * Add joins for one or more relations
57
     * This determines the foreign key relations automatically to prevent the need to figure out the columns.
58
     * Usages:
59
     * $query->modelJoin('customers')
60
     * $query->modelJoin('customer.client')
61
     *
62
     * @param Builder $query
63
     * @param string $relationName
64
     * @param string $operator
65
     * @param string $type
66
     * @param bool $where
67
     * @param bool $relatedSelect
68
     * @param string|null $direction
69
     *
70
     * @return Builder
71
     */
72
    public function scopeModelJoin(
73
        Builder $query,
74
        $relationName,
75
        $operator = '=',
76
        $type = 'left',
77
        $where = false,
78
        $relatedSelect = true,
79
        $direction = null
80
    ) {
81
        $connection = $this->connection;
82
83
        foreach ($this->parseRelationNames($relationName) as $relation) {
84
            $tableName = $relation->getRelated()->getTable();
85
            // if using a 'table' AS 'tableAlias' in a from statement, otherwise alias will be the table name
86
            $from = explode(' ', $relation->getQuery()->getQuery()->from);
87
            $tableAlias = array_pop($from);
88
89
            /** @var Model $query */
90
            if (empty($query->getQuery()->columns)) {
91
                $query->select($this->getTable() . ".*");
92
            }
93
            if ($relatedSelect) {
94
                foreach (Schema::connection($connection)->getColumnListing($tableName) as $relatedColumn) {
95
                    $query->addSelect(
96
                        new Expression("`$tableAlias`.`$relatedColumn` AS `$tableAlias.$relatedColumn`")
97
                    );
98
                }
99
            }
100
            $query->relationJoin($tableName, $tableAlias, $relation, $operator, $type, $where, $direction);
101
        }
102
103
        return $query;
104
    }
105
106
    /**
107
     * Join a model
108
     *
109
     * @param Builder $query
110
     * @param string $tableName
111
     * @param string $tableAlias
112
     * @param Relation $relation
113
     * @param string $operator
114
     * @param string $type
115
     * @param boolean $where
116
     * @param null $direction
117
     * @return Builder
118
     */
119
    public function scopeRelationJoin(
120
        Builder $query,
121
        $tableName,
122
        $tableAlias,
123
        $relation,
124
        $operator,
125
        $type,
126
        $where,
127
        $direction = null
128
    ) {
129
        if ($tableAlias !== '' && $tableName !== $tableAlias) {
130
            $fullTableName = $tableName . ' AS ' . $tableAlias;
131
        } else {
132
            $fullTableName = $tableName;
133
        }
134
135
        return $query->join($fullTableName, function (JoinClause $join) use (
136
            $tableName,
137
            $tableAlias,
138
            $relation,
139
            $operator,
140
            $direction
141
        ) {
142
            // If a HasOne relation and ordered - ie join to the latest/earliest
143
            if (class_basename($relation) === 'HasOne' && !empty($relation->toBase()->orders)) {
144
                return $this->hasOneJoin($relation, $join);
145
            } else {
146
                return $this->hasManyJoin($relation, $join, $tableName, $tableAlias, $operator, $direction);
147
            }
148
        }, null, null, $type, $where);
149
    }
150
151
    /**
152
     * Join a HasOne relation which is ordered
153
     *
154
     * @param Relation $relation
155
     * @param JoinClause $join
156
     * @return JoinClause
157
     */
158
    private function hasOneJoin($relation, $join)
159
    {
160
        // Get first relation order (should only be one)
161
        $order = $relation->toBase()->orders[0];
162
163
        return $join->on($order['column'], $this->hasOneJoinSql($relation, $order));
164
    }
165
166
    /**
167
     * Get join sql for a HasOne relation
168
     *
169
     * @param Relation $relation
170
     * @param array $order
171
     * @return Expression
172
     */
173
    public function hasOneJoinSql($relation, $order)
174
    {
175
        // Build subquery for getting first/last record in related table
176
        $subQuery = $this
177
            ->joinOne(
178
                $relation->getRelated()->newQuery(),
179
                $relation,
180
                $order['column'],
181
                $order['direction']
182
            )
183
            ->setBindings($relation->getBindings());
184
185
        return DB::raw('(' . $this->toSqlWithBindings($subQuery) . ')');
186
    }
187
188
    /**
189
     * Adds a where for a relation's join columns and and min/max for a given column
190
     *
191
     * @param Builder $query
192
     * @param Relation $relation
193
     * @param string $column
194
     * @param string $direction
195
     * @return Builder
196
     */
197
    public function joinOne($query, $relation, $column, $direction)
198
    {
199
        // Get join fields
200
        $joinColumns = $this->getJoinColumns($relation);
201
202
        return $this->selectMinMax(
203
            $query->whereColumn($joinColumns->first, '=', $joinColumns->second),
204
            $column,
205
            $direction
206
        );
207
    }
208
209
    /**
210
     * Get the join columns for a relation
211
     *
212
     * @param Relation|BelongsTo|HasOneOrMany $relation
213
     * @return \stdClass
214
     */
215
    protected function getJoinColumns($relation)
216
    {
217
        // Get keys with table names
218
        if ($relation instanceof BelongsTo) {
219
            $first = $relation->getOwnerKey();
220
            $second = $relation->getForeignKey();
221
        } else {
222
            $first = $relation->getQualifiedParentKeyName();
223
            $second = $relation->getQualifiedForeignKeyName();
224
        }
225
226
        return (object)['first' => $first, 'second' => $second];
227
    }
228
229
    /**
230
     * Adds a select for a min or max on the given column, depending on direction given
231
     *
232
     * @param Builder $query
233
     * @param string $column
234
     * @param string $direction
235
     * @return Builder
236
     */
237
    public function selectMinMax($query, $column, $direction)
238
    {
239
        $column = $this->addBackticks($column);
240
241
        /** @var Model $query */
242
        if ($direction == 'asc') {
243
            return $query->select(DB::raw('MIN(' . $column . ')'));
244
        } else {
245
            return $query->select(DB::raw('MAX(' . $column . ')'));
246
        }
247
    }
248
249
    /**
250
     * Join a HasMany Relation
251
     *
252
     * @param Relation $relation
253
     * @param JoinClause $join
254
     * @param string $tableName
255
     * @param string $tableAlias
256
     * @param string $operator
257
     * @param string $direction
258
     * @return Builder|JoinClause
259
     */
260
    private function hasManyJoin($relation, $join, $tableName, $tableAlias, $operator, $direction)
261
    {
262
        // Get relation join columns
263
        $joinColumns = $this->getJoinColumns($relation);
264
265
        $first = $joinColumns->first;
266
        $second = $joinColumns->second;
267
        if ($tableName !== $tableAlias) {
268
            $first = str_replace($tableName, $tableAlias, $first);
269
            $second = str_replace($tableName, $tableAlias, $second);
270
        }
271
272
        $join->on($first, $operator, $second);
273
274
        // Add any where clauses from the relationship
275
        $join = $this->addWhereConstraints($join, $relation, $tableAlias);
276
277
        if (!is_null($direction) && get_class($relation) === HasMany::class) {
278
            $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...
279
        }
280
281
        return $join;
282
    }
283
284
    /**
285
     * Add wheres if they exist for a relation
286
     *
287
     * @param Builder|JoinClause $builder
288
     * @param Relation|BelongsTo|HasOneOrMany $relation
289
     * @param string $table
290
     * @return Builder|JoinClause $builder
291
     */
292
    protected function addWhereConstraints($builder, $relation, $table)
293
    {
294
        // Get where clauses from the relationship
295
        $wheres = collect($relation->toBase()->wheres)
296
            ->where('type', 'Basic')
297
            ->map(function ($where) use ($table) {
298
                // Add table name to column if it is absent
299
                return [$this->columnWithTableName($table, $where['column']), $where['operator'], $where['value']];
300
            })->toArray();
301
302
        if (!empty($wheres)) {
303
            $builder->where($wheres);
304
        }
305
306
        return $builder;
307
    }
308
309
    /**
310
     * If the relation is one-to-many, just get the first related record
311
     *
312
     * @param JoinClause $joinClause
313
     * @param string $column
314
     * @param HasMany|Relation $relation
315
     * @param string $table
316
     * @param string $direction
317
     *
318
     * @return JoinClause
319
     */
320
    public function hasManyJoinWhere(JoinClause $joinClause, $column, $relation, $table, $direction)
321
    {
322
        return $joinClause->where(
323
            $column,
324
            function ($subQuery) use ($table, $direction, $relation, $column) {
325
                $subQuery = $this->joinOne(
326
                    $subQuery->from($table),
327
                    $relation,
328
                    $column,
329
                    $direction
330
                );
331
332
                // Add any where statements with the relationship
333
                $subQuery = $this->addWhereConstraints($subQuery, $relation, $table);
334
335
                // Add any order statements with the relationship
336
                return $this->addOrder($subQuery, $relation, $table);
337
            }
338
        );
339
    }
340
341
    /**
342
     * Add orderBy if orders exist for a relation
343
     *
344
     * @param Builder|JoinClause $builder
345
     * @param Relation|BelongsTo|HasOneOrMany $relation
346
     * @param string $table
347
     * @return Builder
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use Model.

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
348
     */
349
    protected function addOrder($builder, $relation, $table)
350
    {
351
        /** @var Model $builder */
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']);
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
     * Join a related table if not already joined
402
     *
403
     * @param Builder $query
404
     * @param string $table
405
     * @return Builder
406
     */
407
    private function joinRelatedTable($query, $table)
408
    {
409
        if (isset($this->order_relations[$table]) &&
410
            !$this->hasJoin($query, $table, $this->order_relations[$table])) {
411
            $columnRelations = $this->order_relations[$table];
412
413
            $query->modelJoin(
414
                $columnRelations,
415
                '=',
416
                'left',
417
                false,
418
                false
419
            );
420
        }
421
422
        return $query;
423
    }
424
425
    /**
426
     * Switch a query to be a subquery of a model
427
     *
428
     * @param Builder $query
429
     * @param Builder $model
430
     * @return Builder
431
     */
432
    public function scopeSetSubquery(Builder $query, $model)
433
    {
434
        $sql = $this->toSqlWithBindings($model);
435
        $table = $model->getQuery()->from;
436
437
        return $query
438
            ->from(DB::raw("({$sql}) as " . $table))
439
            ->select($table . '.*');
440
    }
441
442
    /**
443
     * Use a model method to add columns or joins if in the order options
444
     *
445
     * @param Builder $query
446
     * @param string $order
447
     * @return Builder
448
     */
449
    public function scopeOrderByWith(Builder $query, $order)
450
    {
451
        if (isset($this->order_with[$order])) {
452
            $with = 'with' . $this->order_with[$order];
453
454
            $query->$with();
455
        }
456
457
        if (isset($this->order_fields[$order])) {
458
            $orderOption = (explode('.', $this->order_fields[$order]))[0];
459
460
            if (isset($this->order_relations[$orderOption])) {
461
                $query->modelJoin(
462
                    $this->order_relations[$orderOption],
463
                    '=',
464
                    'left',
465
                    false,
466
                    false
467
                );
468
            }
469
        }
470
471
        return $query;
472
    }
473
474
    /**
475
     * Add where statements for the model search fields
476
     *
477
     * @param Builder $query
478
     * @param string $searchText
479
     * @return Builder
480
     */
481
    public function scopeSearch(Builder $query, $searchText = '')
482
    {
483
        $searchText = trim($searchText);
484
485
        // If search is set
486
        if ($searchText != "") {
487
            if (!isset($this->search_fields) || !is_array($this->search_fields) || empty($this->search_fields)) {
488
                throw new InvalidArgumentException(get_class($this) . ' search properties not set correctly.');
489
            } else {
490
                $query = $this->checkSearchFields($query, $searchText);
491
            }
492
        }
493
494
        return $query;
495
    }
496
497
    /**
498
     * Add where statements for search fields to search for searchText
499
     *
500
     * @param Builder $query
501
     * @param string $searchText
502
     * @return Builder
503
     */
504
    private function checkSearchFields($query, $searchText)
505
    {
506
        return $query->where(function (Builder $query) use ($searchText) {
507
            if (isset($this->search_fields) && !empty($this->search_fields)) {
508
                /** @var Model $this */
509
                $table = $this->getTable();
510
                foreach ($this->search_fields as $searchField => $searchFieldParameters) {
511
                    $query = $this->checkSearchField($query, $table, $searchField, $searchFieldParameters, $searchText);
512
                }
513
            }
514
515
            return $query;
516
        });
517
    }
518
519
    /**
520
     * Add where statement for a search field
521
     *
522
     * @param Builder $query
523
     * @param string $table
524
     * @param string $searchField
525
     * @param array $searchFieldParameters
526
     * @param string $searchText
527
     * @return Builder
528
     */
529
    private function checkSearchField($query, $table, $searchField, $searchFieldParameters, $searchText)
530
    {
531
        if (!isset($searchFieldParameters['regex']) || preg_match($searchFieldParameters['regex'], $searchText)) {
532
            $searchColumn = is_array($searchFieldParameters) ? $searchField : $searchFieldParameters;
533
534
            if (isset($searchFieldParameters['relation'])) {
535
                return $this->searchRelation($query, $searchFieldParameters, $searchColumn, $searchText);
536
            } else {
537
                return $this->searchThis($query, $searchFieldParameters, $table, $searchColumn, $searchText);
538
            }
539
        } else {
540
            return $query;
541
        }
542
    }
543
544
    /**
545
     * Add where condition to search a relation
546
     *
547
     * @param Builder $query
548
     * @param array $searchFieldParameters
549
     * @param string $searchColumn
550
     * @param string $searchText
551
     * @return Builder
552
     */
553
    private function searchRelation(Builder $query, $searchFieldParameters, $searchColumn, $searchText)
554
    {
555
        $relation = $searchFieldParameters['relation'];
556
        $relatedTable = $this->$relation()->getRelated()->getTable();
557
558
        return $query->orWhere(function (Builder $query) use (
559
            $searchText,
560
            $searchColumn,
561
            $searchFieldParameters,
562
            $relation,
563
            $relatedTable
564
        ) {
565
            return $query->orWhereHas($relation, function (Builder $query2) use (
566
                $searchText,
567
                $searchColumn,
568
                $searchFieldParameters,
569
                $relatedTable
570
            ) {
571
                return $query2->where($relatedTable . '.' . $searchColumn, 'like', $searchText . '%');
572
            });
573
        });
574
    }
575
576
    /**
577
     * Add where condition to search current model
578
     *
579
     * @param Builder $query
580
     * @param array $searchFieldParameters
581
     * @param string $table
582
     * @param string $searchColumn
583
     * @param string $searchText
584
     * @return Builder
585
     */
586
    public function searchThis(Builder $query, $searchFieldParameters, $table, $searchColumn, $searchText)
587
    {
588
        $searchOperator = $searchFieldParameters['operator'] ?? 'like';
589
        $searchValue = $searchFieldParameters['value'] ?? '%{{search}}%';
590
591
        return $query->orWhere(
592
            $table . '.' . $searchColumn,
593
            $searchOperator,
594
            str_replace('{{search}}', $searchText, $searchValue)
595
        );
596
    }
597
}
598