Skip to content
Open
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
18 changes: 15 additions & 3 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ public function fromSubquery(BaseBuilder $from, string $alias): self
*
* @return $this
*/
public function join(string $table, $cond, string $type = '', ?bool $escape = null)
public function join(RawSql|string $table, $cond, string $type = '', ?bool $escape = null)
{
$type = $this->compileJoinType($type);

Expand All @@ -676,8 +676,12 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu
/**
* Compiles the JOIN table name.
*/
protected function compileJoinTable(string $table, bool $escape): string
protected function compileJoinTable(RawSql|string $table, bool $escape): string
{
if ($table instanceof RawSql) {
return (string) $table;
}

if ($escape) {
return $this->db->protectIdentifiers($table, true, null, false);
}
Expand Down Expand Up @@ -3726,12 +3730,20 @@ protected function _delete(string $table): string
/**
* Used to track SQL statements written with aliased tables.
*
* @param array<array-key, string>|string $table The table to inspect
* @param array<array-key, string>|RawSql|string $table The table to inspect
*
* @return string|null
*/
protected function trackAliases($table)
{
if ($table instanceof RawSql) {
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)) {
$this->db->addTableAlias(trim($matches[1], '"`[]'));
}

return null;
}

if (is_array($table)) {
foreach ($table as $t) {
$this->trackAliases($t);
Expand Down
2 changes: 1 addition & 1 deletion system/Database/Postgre/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ protected function _like_statement(?string $prefix, string $column, ?string $not
*
* @return $this
*/
public function join(string $table, $cond, string $type = '', ?bool $escape = null)
public function join(RawSql|string $table, $cond, string $type = '', ?bool $escape = null)
{
if (! in_array('FULL OUTER', $this->joinTypes, true)) {
$this->joinTypes = array_merge($this->joinTypes, ['FULL OUTER']);
Expand Down
6 changes: 5 additions & 1 deletion system/Database/SQLSRV/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,12 @@ protected function _truncate(string $table): string
return 'TRUNCATE TABLE ' . $this->getFullName($table);
}

protected function compileJoinTable(string $table, bool $escape): string
protected function compileJoinTable(RawSql|string $table, bool $escape): string
{
if ($table instanceof RawSql) {
return (string) $table;
}

if ($escape) {
$table = $this->db->protectIdentifiers($table, true, null, false);
}
Expand Down
74 changes: 74 additions & 0 deletions tests/system/Database/Builder/JoinTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,68 @@ public function testJoinRawSql(): void
$this->assertSame($expectedSQL, $output);
}

public function testJoinRawSqlTable(): void
{
$builder = new BaseBuilder('users', $this->db);

$builder->join(
new RawSql('(SELECT user_id, MAX(created_at) AS latest FROM posts GROUP BY user_id) recent'),
'recent.user_id = users.id',
'LEFT',
);

$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"';

$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
}

public function testJoinRawSqlTableWithPrefix(): void
{
$this->db = new MockConnection(['DBPrefix' => 'ci_']);
$builder = new BaseBuilder('users', $this->db);

$builder->join(new RawSql('(SELECT user_id FROM posts) recent'), 'recent.user_id = users.id');

$expectedSQL = 'SELECT * FROM "ci_users" JOIN (SELECT user_id FROM posts) recent ON "recent"."user_id" = "ci_users"."id"';

$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
}

public function testJoinRawSqlOrdinaryTableAliasWithPrefix(): void
{
$this->db = new MockConnection(['DBPrefix' => 'ci_']);
$builder = new BaseBuilder('users', $this->db);

$builder->join(new RawSql('posts recent'), 'recent.user_id = users.id');

$expectedSQL = 'SELECT * FROM "ci_users" JOIN posts recent ON "recent"."user_id" = "ci_users"."id"';

$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
}

public function testJoinRawSqlQuotedSubqueryAliasWithPrefix(): void
{
$this->db = new MockConnection(['DBPrefix' => 'ci_']);
$builder = new BaseBuilder('users', $this->db);

$builder->join(new RawSql('(SELECT user_id FROM posts) AS "recent"'), 'recent.user_id = users.id');

$expectedSQL = 'SELECT * FROM "ci_users" JOIN (SELECT user_id FROM posts) AS "recent" ON "recent"."user_id" = "ci_users"."id"';

$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
}

public function testPostgreJoinRawSqlTable(): void
{
$builder = new PostgreBuilder('users', $this->db);

$builder->join(new RawSql('(SELECT user_id FROM posts) recent'), 'recent.user_id = users.id', 'FULL OUTER');

$expectedSQL = 'SELECT * FROM "users" FULL OUTER JOIN (SELECT user_id FROM posts) recent ON "recent"."user_id" = "users"."id"';

$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
}

public function testFullOuterJoin(): void
{
$builder = new PostgreBuilder('jobs', $this->db);
Expand Down Expand Up @@ -170,4 +232,16 @@ public function testSqlsrvJoinRawSql(): void

$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
}

public function testSqlsrvJoinRawSqlTable(): void
{
$this->db = new MockConnection(['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo']);

$builder = new SQLSRVBuilder('users', $this->db);
$builder->join(new RawSql('(SELECT user_id FROM posts) recent'), 'recent.user_id = users.id', 'LEFT');

$expectedSQL = 'SELECT * FROM "test"."dbo"."users" LEFT JOIN (SELECT user_id FROM posts) recent ON "recent"."user_id" = "users"."id"';

$this->assertSameSql($expectedSQL, $builder->getCompiledSelect());
}
}
2 changes: 2 additions & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ update your implementations to include the new methods or method changes to ensu
Method Signature Changes
========================

- **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`.
- **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``.
- **CodeIgniter:** The deprecated parameters in methods have been removed:
- ``CodeIgniter\CodeIgniter::handleRequest()`` no longer accepts the deprecated ``$cacheConfig`` and ``$returnResponse`` parameters.
Expand Down Expand Up @@ -281,6 +282,7 @@ Database
Query Builder
-------------

- ``BaseBuilder::join()`` now accepts ``RawSql`` as the table argument, allowing raw JOIN targets such as subqueries. See :ref:`query-builder-join-rawsql`.
- 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`.
- Added ``explain()`` to Query Builder to run execution-plan queries for the current ``SELECT`` query. See :ref:`query-builder-explain`.
- Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`.
Expand Down
18 changes: 16 additions & 2 deletions user_guide_src/source/database/query_builder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,19 @@ Since v4.2.0, ``$builder->join()`` accepts a ``CodeIgniter\Database\RawSql`` ins

.. literalinclude:: query_builder/102.php

Since v4.8.0, the table can also be a ``RawSql`` instance. For example, you
can join a subquery:

.. code-block:: php

use CodeIgniter\Database\RawSql;

$builder->join(
new RawSql('(SELECT user_id, MAX(created_at) AS latest FROM posts GROUP BY user_id) recent'),
'recent.user_id = users.id',
'LEFT'
);

.. warning:: When you use ``RawSql``, you MUST escape the values and protect the identifiers manually. Failure to do so could result in SQL injections.

*************************
Expand Down Expand Up @@ -1996,15 +2009,16 @@ Class Reference

.. php:method:: join($table, $cond[, $type = ''[, $escape = null]])

:param string $table: Table name to join
:param string|RawSql $table: Table name or raw SQL to join
:param string|RawSql $cond: The JOIN ON condition
:param string $type: The JOIN type
:param bool $escape: Whether to escape values and identifiers
:returns: ``BaseBuilder`` instance (method chaining)
:rtype: ``BaseBuilder``

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

.. php:method:: where($key[, $value = null[, $escape = null]])

Expand Down
9 changes: 9 additions & 0 deletions user_guide_src/source/installation/upgrade_480.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ Mandatory File Changes
Breaking Changes
****************

Query Builder JOIN Overrides
============================

``BaseBuilder::join()`` and ``BaseBuilder::compileJoinTable()`` now accept
``RawSql|string`` for the table argument. If a custom Query Builder subclass
overrides either method, change its ``$table`` parameter type from ``string``
to ``RawSql|string``. The method body must also handle ``RawSql`` without
escaping or prefixing it.

Console Exit Codes
==================

Expand Down
Loading