diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index b09fd1a3797b..e5577c30e76d 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -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); @@ -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); } @@ -3726,12 +3730,20 @@ protected function _delete(string $table): string /** * Used to track SQL statements written with aliased tables. * - * @param array|string $table The table to inspect + * @param array|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); diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 8c23ac42501d..10d56d85f085 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -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']); diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 23bae8e9b54f..f51f0d266326 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -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); } diff --git a/tests/system/Database/Builder/JoinTest.php b/tests/system/Database/Builder/JoinTest.php index 99febc6679f4..909387cc5a6f 100644 --- a/tests/system/Database/Builder/JoinTest.php +++ b/tests/system/Database/Builder/JoinTest.php @@ -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); @@ -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()); + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index a7dac4c93afe..ff5f72cdaa26 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -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. @@ -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`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 54e44cf0f36b..3b2631bc274e 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -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. ************************* @@ -1996,7 +2009,7 @@ 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 @@ -2004,7 +2017,8 @@ Class Reference :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]]) diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index 7aebe713dc65..2f9e6333bd45 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -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 ==================