diff --git a/src/Query/Traits/JoinTrait.php b/src/Query/Traits/JoinTrait.php index 12e091b4..c5d991e6 100644 --- a/src/Query/Traits/JoinTrait.php +++ b/src/Query/Traits/JoinTrait.php @@ -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. * diff --git a/tests/Database/Functional/Driver/Common/Query/SelectWithJoinQueryTest.php b/tests/Database/Functional/Driver/Common/Query/SelectWithJoinQueryTest.php index c49b21d0..b0741799 100644 --- a/tests/Database/Functional/Driver/Common/Query/SelectWithJoinQueryTest.php +++ b/tests/Database/Functional/Driver/Common/Query/SelectWithJoinQueryTest.php @@ -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() diff --git a/tests/Database/Unit/Query/Tokens/SelectQueryTest.php b/tests/Database/Unit/Query/Tokens/SelectQueryTest.php index 61f90e0a..fc02bea2 100644 --- a/tests/Database/Unit/Query/Tokens/SelectQueryTest.php +++ b/tests/Database/Unit/Query/Tokens/SelectQueryTest.php @@ -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'], + ); + } }