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
|
|||||||
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
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
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. ![]() |
|||||||
67 | 12 | $this->query->select("{$root->getTable()}.*"); |
|||||
0 ignored issues
–
show
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
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. ![]() |
|||||||
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
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
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. ![]() |
|||||||
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 |
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.