Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/Query/Traits/JoinTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,49 @@ public function orOnWhere(mixed ...$args): self
return $this;
}

/**
* Wrap all currently registered ON conditions of the last registered JOIN into a single
* nested AND-group. Mirror of {@see WhereTrait::wrapWhere()} for JOIN ON tokens.
*
* After the call, the last JOIN's ON-state holds exactly one top-level token — a nested
* group containing everything added before. Subsequent `onWhere/orOnWhere/...` calls
* append at the top level alongside this group:
*
* $q->leftJoin('posts')->on('posts.user_id', 'users.id')
* ->onWhere('posts.published', true)
* ->orOnWhere('posts.featured', true)
* ->wrapOnWhere()
* ->onWhere('posts.archived', false);
* // ... LEFT JOIN posts ON posts.user_id = users.id
* // AND (posts.published = ? OR posts.featured = ?)
* // AND posts.archived = ?
*
* No-op when no JOIN has been registered yet, or when the last JOIN has no ON tokens.
*
* Typical use case: ORM scopes attached to joined relation loaders that must stay
* protected from a later `orOnWhere` added by user code.
*
* @return $this|self
*/
public function wrapOnWhere(): self
{
if (
$this->lastJoin === null
|| !isset($this->joinTokens[$this->lastJoin]['on'])
|| $this->joinTokens[$this->lastJoin]['on'] === []
) {
return $this;
}

$this->joinTokens[$this->lastJoin]['on'] = [
['AND', '('],
...$this->joinTokens[$this->lastJoin]['on'],
['', ')'],
];

return $this;
}

/**
* Convert various amount of where function arguments into valid where token.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,96 @@ public function testJoinQuery(): void
);
}

public function testWrapOnWhereGroupsAllExistingOnTokens(): void
{
// `on()` and `onWhere()` share the same internal token list, so wrapOnWhere
// encloses the join key together with onWhere conditions. This is harmless
// (equality join key inside the group keeps the same logical result) and
// even more protective against later orOnWhere additions.
$select = $this->database->select()
->from(['users'])
->leftJoin('posts')
->on('posts.user_id', 'users.id')
->onWhere('posts.published', true)
->orOnWhere('posts.featured', true)
->wrapOnWhere()
->onWhere('posts.archived', false);

$this->assertSameQueryWithParameters(
'SELECT * FROM {users} LEFT JOIN {posts}
ON ({posts}.{user_id} = {users}.{id}
AND {posts}.{published} = ? OR {posts}.{featured} = ?)
AND {posts}.{archived} = ?',
[true, true, false],
$select,
);
}

public function testWrapOnWhereProtectsScopeFromLaterOrOnWhere(): void
{
// Mirrors the soft-delete-style scope on a joined relation: condition added
// first, then wrapped, then a user-supplied orOnWhere — without wrapOnWhere
// the scope would be lost on the OR arm.
$select = $this->database->select()
->from(['users'])
->leftJoin('posts')
->on('posts.user_id', 'users.id')
->onWhere('posts.deleted_at', null)
->wrapOnWhere()
->orOnWhere('posts.id', 5);

$this->assertSameQueryWithParameters(
'SELECT * FROM {users} LEFT JOIN {posts}
ON ({posts}.{user_id} = {users}.{id}
AND {posts}.{deleted_at} IS NULL)
OR {posts}.{id} = ?',
[5],
$select,
);
}

public function testWrapOnWhereWithNoExistingOnTokensIsNoop(): void
{
// Join registered but with no on/onWhere yet — wrapOnWhere does nothing.
$select = $this->database->select()
->from(['users'])
->leftJoin('posts')
->wrapOnWhere()
->onWhere('posts.published', true);

$this->assertSameQueryWithParameters(
'SELECT * FROM {users} LEFT JOIN {posts}
ON {posts}.{published} = ?',
[true],
$select,
);
}

public function testWrapOnWhereTargetsOnlyLastRegisteredJoinInSql(): void
{
$select = $this->database->select()
->from(['users'])
->leftJoin('posts')
->on('posts.user_id', 'users.id')
->onWhere('posts.published', true)
->orOnWhere('posts.featured', true)
->leftJoin('comments')
->on('comments.user_id', 'users.id')
->onWhere('comments.approved', true)
->orOnWhere('comments.pinned', true)
->wrapOnWhere(); // affects only the second join

$this->assertSameQueryWithParameters(
'SELECT * FROM {users}
LEFT JOIN {posts} ON {posts}.{user_id} = {users}.{id}
AND {posts}.{published} = ? OR {posts}.{featured} = ?
LEFT JOIN {comments} ON ({comments}.{user_id} = {users}.{id}
AND {comments}.{approved} = ? OR {comments}.{pinned} = ?)',
[true, true, true, true],
$select,
);
}

public function testJoinQueryWithParameters(): void
{
$subSelect = $this->db('prefixed', 'prefix_')->select()
Expand Down
88 changes: 88 additions & 0 deletions tests/Database/Unit/Query/Tokens/SelectQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,92 @@ public function testWrapWhereProtectsAgainstLaterOrWhere(): void
$select->getTokens()['where'],
);
}

public function testWrapOnWhereWithoutAnyJoinIsNoop(): void
{
$select = (new SelectQuery())->from('table');
$select->wrapOnWhere();

$this->assertSame([], $select->getTokens()['join']);
}

public function testWrapOnWhereWithEmptyOnTokensIsNoop(): void
{
$select = (new SelectQuery())
->from('table')
->leftJoin('joined');
$select->wrapOnWhere();

$this->assertSame([], $select->getTokens()['join'][1]['on']);
}

public function testWrapOnWhereEnclosesExistingOnTokens(): void
{
$select = (new SelectQuery())
->from('table')
->leftJoin('joined')
->onWhere('joined.a', 1)
->orOnWhere('joined.a', 2)
->wrapOnWhere()
->onWhere('joined.b', 3);

$this->assertEquals(
[
['AND', '('],
['AND', ['joined.a', '=', new Parameter(1)]],
['OR', ['joined.a', '=', new Parameter(2)]],
['', ')'],
['AND', ['joined.b', '=', new Parameter(3)]],
],
$select->getTokens()['join'][1]['on'],
);
}

public function testWrapOnWhereTargetsOnlyLastRegisteredJoin(): void
{
$select = (new SelectQuery())
->from('table')
->leftJoin('first')->onWhere('first.x', 1)->orOnWhere('first.x', 2)
->leftJoin('second')->onWhere('second.y', 10)->orOnWhere('second.y', 20)
->wrapOnWhere(); // affects only the second join — last registered

$joins = $select->getTokens()['join'];

$this->assertEquals(
[
['AND', ['first.x', '=', new Parameter(1)]],
['OR', ['first.x', '=', new Parameter(2)]],
],
$joins[1]['on'],
);

$this->assertEquals(
[
['AND', '('],
['AND', ['second.y', '=', new Parameter(10)]],
['OR', ['second.y', '=', new Parameter(20)]],
['', ')'],
],
$joins[2]['on'],
);
}

public function testWrapOnWhereDoesNotAffectWhereTokens(): void
{
$select = (new SelectQuery())
->from('table')
->where('a', 1)
->orWhere('a', 2)
->leftJoin('joined')->onWhere('joined.b', 3)->orOnWhere('joined.b', 4)
->wrapOnWhere();

// WHERE stays flat — only the join's ON gets wrapped.
$this->assertEquals(
[
['AND', ['a', '=', new Parameter(1)]],
['OR', ['a', '=', new Parameter(2)]],
],
$select->getTokens()['where'],
);
}
}
Loading