Skip to content

Commit aafa2f6

Browse files
roxblnfkgithub-actions
andauthored
feat: Add wrapOnWhere() method to group JOIN ON conditions (#253)
Co-authored-by: github-actions <github-actions@users.noreply.github.com>
1 parent a064944 commit aafa2f6

3 files changed

Lines changed: 221 additions & 0 deletions

File tree

src/Query/Traits/JoinTrait.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,49 @@ public function orOnWhere(mixed ...$args): self
329329
return $this;
330330
}
331331

332+
/**
333+
* Wrap all currently registered ON conditions of the last registered JOIN into a single
334+
* nested AND-group. Mirror of {@see WhereTrait::wrapWhere()} for JOIN ON tokens.
335+
*
336+
* After the call, the last JOIN's ON-state holds exactly one top-level token — a nested
337+
* group containing everything added before. Subsequent `onWhere/orOnWhere/...` calls
338+
* append at the top level alongside this group:
339+
*
340+
* $q->leftJoin('posts')->on('posts.user_id', 'users.id')
341+
* ->onWhere('posts.published', true)
342+
* ->orOnWhere('posts.featured', true)
343+
* ->wrapOnWhere()
344+
* ->onWhere('posts.archived', false);
345+
* // ... LEFT JOIN posts ON posts.user_id = users.id
346+
* // AND (posts.published = ? OR posts.featured = ?)
347+
* // AND posts.archived = ?
348+
*
349+
* No-op when no JOIN has been registered yet, or when the last JOIN has no ON tokens.
350+
*
351+
* Typical use case: ORM scopes attached to joined relation loaders that must stay
352+
* protected from a later `orOnWhere` added by user code.
353+
*
354+
* @return $this|self
355+
*/
356+
public function wrapOnWhere(): self
357+
{
358+
if (
359+
$this->lastJoin === null
360+
|| !isset($this->joinTokens[$this->lastJoin]['on'])
361+
|| $this->joinTokens[$this->lastJoin]['on'] === []
362+
) {
363+
return $this;
364+
}
365+
366+
$this->joinTokens[$this->lastJoin]['on'] = [
367+
['AND', '('],
368+
...$this->joinTokens[$this->lastJoin]['on'],
369+
['', ')'],
370+
];
371+
372+
return $this;
373+
}
374+
332375
/**
333376
* Convert various amount of where function arguments into valid where token.
334377
*

tests/Database/Functional/Driver/Common/Query/SelectWithJoinQueryTest.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,96 @@ public function testJoinQuery(): void
654654
);
655655
}
656656

657+
public function testWrapOnWhereGroupsAllExistingOnTokens(): void
658+
{
659+
// `on()` and `onWhere()` share the same internal token list, so wrapOnWhere
660+
// encloses the join key together with onWhere conditions. This is harmless
661+
// (equality join key inside the group keeps the same logical result) and
662+
// even more protective against later orOnWhere additions.
663+
$select = $this->database->select()
664+
->from(['users'])
665+
->leftJoin('posts')
666+
->on('posts.user_id', 'users.id')
667+
->onWhere('posts.published', true)
668+
->orOnWhere('posts.featured', true)
669+
->wrapOnWhere()
670+
->onWhere('posts.archived', false);
671+
672+
$this->assertSameQueryWithParameters(
673+
'SELECT * FROM {users} LEFT JOIN {posts}
674+
ON ({posts}.{user_id} = {users}.{id}
675+
AND {posts}.{published} = ? OR {posts}.{featured} = ?)
676+
AND {posts}.{archived} = ?',
677+
[true, true, false],
678+
$select,
679+
);
680+
}
681+
682+
public function testWrapOnWhereProtectsScopeFromLaterOrOnWhere(): void
683+
{
684+
// Mirrors the soft-delete-style scope on a joined relation: condition added
685+
// first, then wrapped, then a user-supplied orOnWhere — without wrapOnWhere
686+
// the scope would be lost on the OR arm.
687+
$select = $this->database->select()
688+
->from(['users'])
689+
->leftJoin('posts')
690+
->on('posts.user_id', 'users.id')
691+
->onWhere('posts.deleted_at', null)
692+
->wrapOnWhere()
693+
->orOnWhere('posts.id', 5);
694+
695+
$this->assertSameQueryWithParameters(
696+
'SELECT * FROM {users} LEFT JOIN {posts}
697+
ON ({posts}.{user_id} = {users}.{id}
698+
AND {posts}.{deleted_at} IS NULL)
699+
OR {posts}.{id} = ?',
700+
[5],
701+
$select,
702+
);
703+
}
704+
705+
public function testWrapOnWhereWithNoExistingOnTokensIsNoop(): void
706+
{
707+
// Join registered but with no on/onWhere yet — wrapOnWhere does nothing.
708+
$select = $this->database->select()
709+
->from(['users'])
710+
->leftJoin('posts')
711+
->wrapOnWhere()
712+
->onWhere('posts.published', true);
713+
714+
$this->assertSameQueryWithParameters(
715+
'SELECT * FROM {users} LEFT JOIN {posts}
716+
ON {posts}.{published} = ?',
717+
[true],
718+
$select,
719+
);
720+
}
721+
722+
public function testWrapOnWhereTargetsOnlyLastRegisteredJoinInSql(): void
723+
{
724+
$select = $this->database->select()
725+
->from(['users'])
726+
->leftJoin('posts')
727+
->on('posts.user_id', 'users.id')
728+
->onWhere('posts.published', true)
729+
->orOnWhere('posts.featured', true)
730+
->leftJoin('comments')
731+
->on('comments.user_id', 'users.id')
732+
->onWhere('comments.approved', true)
733+
->orOnWhere('comments.pinned', true)
734+
->wrapOnWhere(); // affects only the second join
735+
736+
$this->assertSameQueryWithParameters(
737+
'SELECT * FROM {users}
738+
LEFT JOIN {posts} ON {posts}.{user_id} = {users}.{id}
739+
AND {posts}.{published} = ? OR {posts}.{featured} = ?
740+
LEFT JOIN {comments} ON ({comments}.{user_id} = {users}.{id}
741+
AND {comments}.{approved} = ? OR {comments}.{pinned} = ?)',
742+
[true, true, true, true],
743+
$select,
744+
);
745+
}
746+
657747
public function testJoinQueryWithParameters(): void
658748
{
659749
$subSelect = $this->db('prefixed', 'prefix_')->select()

tests/Database/Unit/Query/Tokens/SelectQueryTest.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,92 @@ public function testWrapWhereProtectsAgainstLaterOrWhere(): void
116116
$select->getTokens()['where'],
117117
);
118118
}
119+
120+
public function testWrapOnWhereWithoutAnyJoinIsNoop(): void
121+
{
122+
$select = (new SelectQuery())->from('table');
123+
$select->wrapOnWhere();
124+
125+
$this->assertSame([], $select->getTokens()['join']);
126+
}
127+
128+
public function testWrapOnWhereWithEmptyOnTokensIsNoop(): void
129+
{
130+
$select = (new SelectQuery())
131+
->from('table')
132+
->leftJoin('joined');
133+
$select->wrapOnWhere();
134+
135+
$this->assertSame([], $select->getTokens()['join'][1]['on']);
136+
}
137+
138+
public function testWrapOnWhereEnclosesExistingOnTokens(): void
139+
{
140+
$select = (new SelectQuery())
141+
->from('table')
142+
->leftJoin('joined')
143+
->onWhere('joined.a', 1)
144+
->orOnWhere('joined.a', 2)
145+
->wrapOnWhere()
146+
->onWhere('joined.b', 3);
147+
148+
$this->assertEquals(
149+
[
150+
['AND', '('],
151+
['AND', ['joined.a', '=', new Parameter(1)]],
152+
['OR', ['joined.a', '=', new Parameter(2)]],
153+
['', ')'],
154+
['AND', ['joined.b', '=', new Parameter(3)]],
155+
],
156+
$select->getTokens()['join'][1]['on'],
157+
);
158+
}
159+
160+
public function testWrapOnWhereTargetsOnlyLastRegisteredJoin(): void
161+
{
162+
$select = (new SelectQuery())
163+
->from('table')
164+
->leftJoin('first')->onWhere('first.x', 1)->orOnWhere('first.x', 2)
165+
->leftJoin('second')->onWhere('second.y', 10)->orOnWhere('second.y', 20)
166+
->wrapOnWhere(); // affects only the second join — last registered
167+
168+
$joins = $select->getTokens()['join'];
169+
170+
$this->assertEquals(
171+
[
172+
['AND', ['first.x', '=', new Parameter(1)]],
173+
['OR', ['first.x', '=', new Parameter(2)]],
174+
],
175+
$joins[1]['on'],
176+
);
177+
178+
$this->assertEquals(
179+
[
180+
['AND', '('],
181+
['AND', ['second.y', '=', new Parameter(10)]],
182+
['OR', ['second.y', '=', new Parameter(20)]],
183+
['', ')'],
184+
],
185+
$joins[2]['on'],
186+
);
187+
}
188+
189+
public function testWrapOnWhereDoesNotAffectWhereTokens(): void
190+
{
191+
$select = (new SelectQuery())
192+
->from('table')
193+
->where('a', 1)
194+
->orWhere('a', 2)
195+
->leftJoin('joined')->onWhere('joined.b', 3)->orOnWhere('joined.b', 4)
196+
->wrapOnWhere();
197+
198+
// WHERE stays flat — only the join's ON gets wrapped.
199+
$this->assertEquals(
200+
[
201+
['AND', ['a', '=', new Parameter(1)]],
202+
['OR', ['a', '=', new Parameter(2)]],
203+
],
204+
$select->getTokens()['where'],
205+
);
206+
}
119207
}

0 commit comments

Comments
 (0)