HasByJoinMacro::overrideTableWithAlias()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 3
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 7
ccs 4
cts 4
cp 1
crap 2
rs 10
1
<?php
2
3
namespace Mpyw\EloquentHasByJoin;
4
5
use DomainException;
6
use Illuminate\Database\Eloquent\Builder;
7
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8
use Illuminate\Database\Eloquent\Relations\HasOne;
9
use Illuminate\Database\Eloquent\Relations\MorphOne;
10
use Illuminate\Database\Eloquent\Relations\MorphTo;
11
use Illuminate\Database\Eloquent\Relations\Relation;
12
use Illuminate\Database\Query\JoinClause;
13
14
/**
15
 * Class HasByJoinMacro
16
 *
17
 * Convert has() and whereHas() constraints to join() ones for single-result relations.
18
 */
19
class HasByJoinMacro
20
{
21
    /**
22
     * @var \Illuminate\Database\Eloquent\Builder
23
     */
24
    protected $query;
25
26
    /**
27
     * HasByJoinMacro constructor.
28
     *
29
     * @param \Illuminate\Database\Eloquent\Builder $query
30
     */
31 15
    public function __construct(Builder $query)
32
    {
33 15
        $this->query = $query;
34
    }
35
36
    /**
37
     * Parse nested constraints and iterate them to apply.
38
     *
39
     * @param  string|string[]                       $relationMethod
40
     * @param  callable[]|null[]                     $constraints
41
     * @return \Illuminate\Database\Eloquent\Builder
42
     */
43 15
    public function __invoke($relationMethod, ?callable ...$constraints): Builder
44
    {
45
        // Prepare a root Model
46 15
        $root = $current = $this->query->getModel();
0 ignored issues
show
Bug introduced by
The method getModel() does not exist on Illuminate\Database\Eloquent\Builder. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

46
        $root = $current = $this->query->/** @scrutinizer ignore-call */ getModel();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
47
48
        // Extract dot-chained expressions
49 15
        $relationMethods = \is_string($relationMethod) ? \explode('.', $relationMethod) : \array_values($relationMethod);
50
51 15
        foreach ($relationMethods as $i => $currentRelationMethod) {
52
            // Extract an alias specified with "as" if exists
53 15
            [$currentRelationMethod, $currentTableAlias] = \preg_split('/\s+as\s+/i', $currentRelationMethod, -1, PREG_SPLIT_NO_EMPTY) + [1 => null];
54
55
            // Create a Relation instance
56 15
            $relation = $current->newModelQuery()->getRelation($currentRelationMethod);
57
58
            // Convert Relation constraints to JOIN ones
59 15
            $this->applyRelationAsJoin($relation, $constraints[$i] ?? null, $currentTableAlias);
60
61
            // Prepare the next Model
62 12
            $current = $relation->getRelated();
63
        }
64
65
        // Prevent the original columns and JOIN target columns from being mixed
66 12
        if (($this->query->getQuery()->columns ?: ['*']) === ['*']) {
0 ignored issues
show
Bug introduced by
The method getQuery() does not exist on Illuminate\Database\Eloquent\Builder. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

66
        if (($this->query->/** @scrutinizer ignore-call */ getQuery()->columns ?: ['*']) === ['*']) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
67 12
            $this->query->select("{$root->getTable()}.*");
0 ignored issues
show
Bug introduced by
The method select() does not exist on Illuminate\Database\Eloquent\Builder. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

67
            $this->query->/** @scrutinizer ignore-call */ 
68
                          select("{$root->getTable()}.*");

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
68
        }
69
70 12
        return $this->query;
71
    }
72
73
    /**
74
     * Convert Relation constraints to JOIN ones.
75
     *
76
     * @param \Illuminate\Database\Eloquent\Relations\Relation $relation
77
     * @param null|callable                                    $constraints
78
     * @param null|string                                      $tableAlias
79
     */
80 15
    protected function applyRelationAsJoin(Relation $relation, ?callable $constraints, ?string $tableAlias): void
81
    {
82
        // Support BelongsTo and HasOne relations only
83 15
        if ((!$relation instanceof BelongsTo || $relation instanceof MorphTo) && !$relation instanceof HasOne && !$relation instanceof MorphOne) {
84 1
            throw new DomainException('Unsupported relation. Currently supported: BelongsTo, HasOne and MorphOne');
85
        }
86
87
        // Generate the same subquery as has() method does
88
        $relationExistenceQuery = $relation
89 14
            ->getRelationExistenceQuery($this->overrideTableWithAlias($relation->getRelated()->newQuery(), $tableAlias), $this->query)
90 14
            ->mergeConstraintsFrom($relationQuery = $this->overrideTableWithAlias($relation->getQuery(), $tableAlias));
91
92
        // Validate table alias availability
93 14
        $this->ensureTableAliasAvailability($relationQuery, $tableAlias);
94
95
        // Apply optional constraints
96 12
        if ($constraints) {
97 2
            $constraints($relationExistenceQuery);
98
        }
99
100
        // Convert Eloquent Builder to Query Builder and evaluate scope constraints
101 12
        $relationExistenceQuery = $relationExistenceQuery->toBase();
102
103
        // Migrate has() constraints to join()
104 12
        $this->query->join(
0 ignored issues
show
Bug introduced by
The method join() does not exist on Illuminate\Database\Eloquent\Builder. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

104
        $this->query->/** @scrutinizer ignore-call */ 
105
                      join(

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
105 12
            $tableAlias ? "$relationExistenceQuery->from as $tableAlias" : $relationExistenceQuery->from,
106 12
            function (JoinClause $join) use ($relationExistenceQuery) {
107 12
                $join->mergeWheres($relationExistenceQuery->wheres, $relationExistenceQuery->bindings);
108 12
            }
109
        );
110
111
        // Migrate extra joins on has() constraints
112 12
        $this->mergeJoins($this->query, $relationQuery);
113
    }
114
115
    /**
116
     * Rewrite table name if its alias is provided.
117
     *
118
     * @param  \Illuminate\Database\Eloquent\Builder $query
119
     * @param  null|string                           $tableAlias
120
     * @return \Illuminate\Database\Eloquent\Builder
121
     */
122 14
    protected function overrideTableWithAlias(Builder $query, ?string $tableAlias): Builder
123
    {
124 14
        if ($tableAlias) {
125 3
            $query->getModel()->setTable($tableAlias);
126
        }
127
128 14
        return $query;
129
    }
130
131
    /**
132
     * mergeJoins() is not provided by Laravel,
133
     * so we need to implement it by ourselves.
134
     *
135
     * @param \Illuminate\Database\Eloquent\Builder $dst
136
     * @param \Illuminate\Database\Eloquent\Builder $src
137
     */
138 12
    protected function mergeJoins(Builder $dst, Builder $src): void
139
    {
140 12
        [$dst, $src] = [$dst->getQuery(), $src->getQuery()];
141
142 12
        if ($src->joins) {
143 1
            $dst->joins = \array_merge((array)$dst->joins, $src->joins);
144
        }
145 12
        if ($src->bindings) {
146 12
            $dst->bindings['join'] = \array_merge((array)$dst->bindings['join'], $src->bindings['join']);
147
        }
148
    }
149
150
    /**
151
     * Extra where() or join() constraints in relation are evaluated early,
152
     * so we cannot apply table aliases to them.
153
     *
154
     * @param \Illuminate\Database\Eloquent\Builder $query
155
     * @param null|string                           $tableAlias
156
     */
157 14
    protected function ensureTableAliasAvailability(Builder $query, ?string $tableAlias): void
158
    {
159 14
        $query = $query->getQuery();
160
161 14
        if (($query->joins || $query->wheres) && $tableAlias) {
162 2
            throw new DomainException('You cannot use table alias when your relation has extra joins or wheres.');
163
        }
164
    }
165
}
166