Skip to content

Commit e0aa3ed

Browse files
authored
feat: allow RawSql JOIN tables (#10571)
1 parent dd9dc90 commit e0aa3ed

9 files changed

Lines changed: 124 additions & 8 deletions

File tree

‎system/Database/BaseBuilder.php‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ public function fromSubquery(BaseBuilder $from, string $alias): self
652652
*
653653
* @return $this
654654
*/
655-
public function join(string $table, $cond, string $type = '', ?bool $escape = null)
655+
public function join(RawSql|string $table, $cond, string $type = '', ?bool $escape = null)
656656
{
657657
$type = $this->compileJoinType($type);
658658

@@ -676,8 +676,12 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu
676676
/**
677677
* Compiles the JOIN table name.
678678
*/
679-
protected function compileJoinTable(string $table, bool $escape): string
679+
protected function compileJoinTable(RawSql|string $table, bool $escape): string
680680
{
681+
if ($table instanceof RawSql) {
682+
return (string) $table;
683+
}
684+
681685
if ($escape) {
682686
return $this->db->protectIdentifiers($table, true, null, false);
683687
}
@@ -3726,12 +3730,20 @@ protected function _delete(string $table): string
37263730
/**
37273731
* Used to track SQL statements written with aliased tables.
37283732
*
3729-
* @param array<array-key, string>|string $table The table to inspect
3733+
* @param array<array-key, string>|RawSql|string $table The table to inspect
37303734
*
37313735
* @return string|null
37323736
*/
37333737
protected function trackAliases($table)
37343738
{
3739+
if ($table instanceof RawSql) {
3740+
if (preg_match('/(?:\)\s+|^[^\s()]+\s+)(?:AS\s+)?("[a-z_][a-z0-9_]*"|`[a-z_][a-z0-9_]*`|\[[a-z_][a-z0-9_]*\]|[a-z_][a-z0-9_]*)\s*$/i', (string) $table, $matches)) {
3741+
$this->db->addTableAlias(trim($matches[1], '"`[]'));
3742+
}
3743+
3744+
return null;
3745+
}
3746+
37353747
if (is_array($table)) {
37363748
foreach ($table as $t) {
37373749
$this->trackAliases($t);

‎system/Database/Postgre/Builder.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ protected function _like_statement(?string $prefix, string $column, ?string $not
369369
*
370370
* @return $this
371371
*/
372-
public function join(string $table, $cond, string $type = '', ?bool $escape = null)
372+
public function join(RawSql|string $table, $cond, string $type = '', ?bool $escape = null)
373373
{
374374
if (! in_array('FULL OUTER', $this->joinTypes, true)) {
375375
$this->joinTypes = array_merge($this->joinTypes, ['FULL OUTER']);

‎system/Database/SQLSRV/Builder.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,12 @@ protected function _truncate(string $table): string
129129
return 'TRUNCATE TABLE ' . $this->getFullName($table);
130130
}
131131

132-
protected function compileJoinTable(string $table, bool $escape): string
132+
protected function compileJoinTable(RawSql|string $table, bool $escape): string
133133
{
134+
if ($table instanceof RawSql) {
135+
return (string) $table;
136+
}
137+
134138
if ($escape) {
135139
$table = $this->db->protectIdentifiers($table, true, null, false);
136140
}

‎system/Model.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
* @method $this havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
6363
* @method $this havingNotBetween(?string $key = null, array<array-key, mixed>|null $values = null, ?bool $escape = null)
6464
* @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
65-
* @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null)
65+
* @method $this join(RawSql|string $table, RawSql|string $cond, string $type = '', ?bool $escape = null)
6666
* @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
6767
* @method $this likeAny(list<RawSql|string> $fields, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
6868
* @method $this limit(?int $value = null, ?int $offset = 0)

‎tests/system/Database/Builder/JoinTest.php‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,68 @@ public function testJoinRawSql(): void
121121
$this->assertSame($expectedSQL, $output);
122122
}
123123

124+
public function testJoinRawSqlTable(): void
125+
{
126+
$builder = new BaseBuilder('users', $this->db);
127+
128+
$builder->join(
129+
new RawSql('(SELECT user_id, MAX(created_at) AS latest FROM posts GROUP BY user_id) recent'),
130+
'recent.user_id = users.id',
131+
'LEFT',
132+
);
133+
134+
$expectedSQL = 'SELECT * FROM "users" LEFT JOIN (SELECT user_id, MAX(created_at) AS latest FROM posts GROUP BY user_id) recent ON "recent"."user_id" = "users"."id"';
135+
136+
$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
137+
}
138+
139+
public function testJoinRawSqlTableWithPrefix(): void
140+
{
141+
$this->db = new MockConnection(['DBPrefix' => 'ci_']);
142+
$builder = new BaseBuilder('users', $this->db);
143+
144+
$builder->join(new RawSql('(SELECT user_id FROM posts) recent'), 'recent.user_id = users.id');
145+
146+
$expectedSQL = 'SELECT * FROM "ci_users" JOIN (SELECT user_id FROM posts) recent ON "recent"."user_id" = "ci_users"."id"';
147+
148+
$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
149+
}
150+
151+
public function testJoinRawSqlOrdinaryTableAliasWithPrefix(): void
152+
{
153+
$this->db = new MockConnection(['DBPrefix' => 'ci_']);
154+
$builder = new BaseBuilder('users', $this->db);
155+
156+
$builder->join(new RawSql('posts recent'), 'recent.user_id = users.id');
157+
158+
$expectedSQL = 'SELECT * FROM "ci_users" JOIN posts recent ON "recent"."user_id" = "ci_users"."id"';
159+
160+
$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
161+
}
162+
163+
public function testJoinRawSqlQuotedSubqueryAliasWithPrefix(): void
164+
{
165+
$this->db = new MockConnection(['DBPrefix' => 'ci_']);
166+
$builder = new BaseBuilder('users', $this->db);
167+
168+
$builder->join(new RawSql('(SELECT user_id FROM posts) AS "recent"'), 'recent.user_id = users.id');
169+
170+
$expectedSQL = 'SELECT * FROM "ci_users" JOIN (SELECT user_id FROM posts) AS "recent" ON "recent"."user_id" = "ci_users"."id"';
171+
172+
$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
173+
}
174+
175+
public function testPostgreJoinRawSqlTable(): void
176+
{
177+
$builder = new PostgreBuilder('users', $this->db);
178+
179+
$builder->join(new RawSql('(SELECT user_id FROM posts) recent'), 'recent.user_id = users.id', 'FULL OUTER');
180+
181+
$expectedSQL = 'SELECT * FROM "users" FULL OUTER JOIN (SELECT user_id FROM posts) recent ON "recent"."user_id" = "users"."id"';
182+
183+
$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
184+
}
185+
124186
public function testFullOuterJoin(): void
125187
{
126188
$builder = new PostgreBuilder('jobs', $this->db);
@@ -170,4 +232,16 @@ public function testSqlsrvJoinRawSql(): void
170232

171233
$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
172234
}
235+
236+
public function testSqlsrvJoinRawSqlTable(): void
237+
{
238+
$this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']);
239+
240+
$builder = new SQLSRVBuilder('users', $this->db);
241+
$builder->join(new RawSql('(SELECT user_id FROM posts) recent'), 'recent.user_id = users.id', 'LEFT');
242+
243+
$expectedSQL = 'SELECT * FROM "test"."dbo"."users" LEFT JOIN (SELECT user_id FROM posts) recent ON "recent"."user_id" = "users"."id"';
244+
245+
$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
246+
}
173247
}

‎user_guide_src/source/changelogs/v4.8.0.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ update your implementations to include the new methods or method changes to ensu
7777
Method Signature Changes
7878
========================
7979

80+
- **Database:** ``BaseBuilder::join()`` and ``BaseBuilder::compileJoinTable()`` now accept ``RawSql|string`` for the table argument. Custom builders overriding either method must accept both types. See :doc:`../installation/upgrade_480`.
8081
- **CLI:** The ``Console::run()`` method now accepts an optional ``array $tokens`` parameter. This allows you to pass an array of command tokens directly to the console runner, which is useful for testing or programmatically running commands. If not provided, it will default to using the global ``$argv``.
8182
- **CodeIgniter:** The deprecated parameters in methods have been removed:
8283
- ``CodeIgniter\CodeIgniter::handleRequest()`` no longer accepts the deprecated ``$cacheConfig`` and ``$returnResponse`` parameters.
@@ -292,6 +293,7 @@ Database
292293
Query Builder
293294
-------------
294295

296+
- ``BaseBuilder::join()`` now accepts ``RawSql`` as the table argument, allowing raw JOIN targets such as subqueries. See :ref:`query-builder-join-rawsql`.
295297
- Added ``exists()`` and ``doesntExist()`` to Query Builder to check whether the current Query Builder query would return at least one row. See :ref:`query-builder-exists`.
296298
- Added ``explain()`` to Query Builder to run execution-plan queries for the current ``SELECT`` query. See :ref:`query-builder-explain`.
297299
- Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`.

‎user_guide_src/source/database/query_builder.rst‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ Since v4.2.0, ``$builder->join()`` accepts a ``CodeIgniter\Database\RawSql`` ins
301301

302302
.. literalinclude:: query_builder/102.php
303303

304+
Since v4.8.0, the table can also be a ``RawSql`` instance. For example, you
305+
can join a subquery:
306+
307+
.. literalinclude:: query_builder/134.php
308+
304309
.. warning:: When you use ``RawSql``, you MUST escape the values and protect the identifiers manually. Failure to do so could result in SQL injections.
305310

306311
*************************
@@ -1996,15 +2001,16 @@ Class Reference
19962001

19972002
.. php:method:: join($table, $cond[, $type = ''[, $escape = null]])
19982003
1999-
:param string $table: Table name to join
2004+
:param string|RawSql $table: Table name or raw SQL to join
20002005
:param string|RawSql $cond: The JOIN ON condition
20012006
:param string $type: The JOIN type
20022007
:param bool $escape: Whether to escape values and identifiers
20032008
:returns: ``BaseBuilder`` instance (method chaining)
20042009
:rtype: ``BaseBuilder``
20052010

20062011
Adds a ``JOIN`` clause to a query. Since v4.2.0, ``RawSql`` can be used
2007-
as the JOIN ON condition. See also :ref:`query-builder-join`.
2012+
as the JOIN ON condition. Since v4.8.0, ``RawSql`` can also be used
2013+
as the table. See also :ref:`query-builder-join`.
20082014

20092015
.. php:method:: where($key[, $value = null[, $escape = null]])
20102016
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
use CodeIgniter\Database\RawSql;
4+
5+
$builder->join(
6+
new RawSql('(SELECT user_id, MAX(created_at) AS latest FROM posts GROUP BY user_id) recent'),
7+
'recent.user_id = users.id',
8+
'LEFT',
9+
);

‎user_guide_src/source/installation/upgrade_480.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ Mandatory File Changes
2020
Breaking Changes
2121
****************
2222

23+
Query Builder JOIN Overrides
24+
============================
25+
26+
``BaseBuilder::join()`` and ``BaseBuilder::compileJoinTable()`` now accept
27+
``RawSql|string`` for the table argument. If a custom Query Builder subclass
28+
overrides either method, change its ``$table`` parameter type from ``string``
29+
to ``RawSql|string``. The method body must also handle ``RawSql`` without
30+
escaping or prefixing it.
31+
2332
Console Exit Codes
2433
==================
2534

0 commit comments

Comments
 (0)