From 0c766a7aaeed2b1fc0cdd1d409bb3aa3b115434e Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 25 May 2026 14:02:43 +0200 Subject: [PATCH 1/5] feat(database): add query builder explain Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 41 +++++ system/Database/OCI8/Builder.php | 8 + system/Database/SQLSRV/Builder.php | 8 + system/Database/SQLite3/Builder.php | 8 + system/Model.php | 14 ++ tests/system/Database/Builder/ExplainTest.php | 143 ++++++++++++++++++ tests/system/Database/Live/ExplainTest.php | 66 ++++++++ tests/system/Models/ExplainModelTest.php | 55 +++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/database/query_builder.rst | 30 ++++ .../source/database/query_builder/129.php | 3 + 11 files changed, 377 insertions(+) create mode 100644 tests/system/Database/Builder/ExplainTest.php create mode 100644 tests/system/Database/Live/ExplainTest.php create mode 100644 tests/system/Models/ExplainModelTest.php create mode 100644 user_guide_src/source/database/query_builder/129.php diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 90af1b4ef409..fe301df38b0c 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -2029,6 +2029,47 @@ public function get(?int $limit = null, int $offset = 0, bool $reset = true) return $result; } + /** + * Explains the select statement based on the other functions called + * and runs the query. + * + * @return BaseResult|false|Query|string SQL string when test mode is enabled. + */ + public function explain(bool $reset = true) + { + $this->assertExplainSupported(); + + $sql = $this->compileExplain($this->compileSelect()); + + $result = $this->testMode + ? $this->compileFinalQuery($sql) + : $this->db->query($sql, $this->binds, false); + + if ($reset) { + $this->resetSelect(); + + // Clear our binds so we don't eat up memory + $this->binds = []; + } + + return $result; + } + + /** + * Ensures the current driver supports explaining Query Builder selects. + */ + protected function assertExplainSupported(): void + { + } + + /** + * Compiles an execution-plan query for the current SELECT query. + */ + protected function compileExplain(string $sql): string + { + return 'EXPLAIN ' . $sql; + } + /** * Generates a platform-specific query string that counts all records in * the particular table diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 4b50b49358fc..11ce3b46b9f5 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -233,6 +233,14 @@ protected function compileLockForUpdate(): string return parent::compileLockForUpdate(); } + /** + * Ensures the current driver supports explaining Query Builder selects. + */ + protected function assertExplainSupported(): void + { + throw new DatabaseException('OCI8 does not support explain().'); + } + /** * Generates a platform-specific batch update string from the supplied data */ diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 558f3f9232ee..9f8b32791bd7 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -716,6 +716,14 @@ protected function compileLockForUpdate(): string return ''; } + /** + * Ensures the current driver supports explaining Query Builder selects. + */ + protected function assertExplainSupported(): void + { + throw new DatabaseException('SQLSRV does not support explain().'); + } + /** * Compiles the select statement based on the other functions called * and runs the query diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index 700eef9e877d..a0403f8827d7 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -67,6 +67,14 @@ protected function compileLockForUpdate(): string return ''; } + /** + * Compiles an execution-plan query for the current SELECT query. + */ + protected function compileExplain(string $sql): string + { + return 'EXPLAIN QUERY PLAN ' . $sql; + } + /** * Replace statement * diff --git a/system/Model.php b/system/Model.php index 892bedd9ccf6..f6b125260398 100644 --- a/system/Model.php +++ b/system/Model.php @@ -16,10 +16,12 @@ use Closure; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException; +use CodeIgniter\Database\Query; use CodeIgniter\Entity\Entity; use CodeIgniter\Exceptions\BadMethodCallException; use CodeIgniter\Exceptions\InvalidArgumentException; @@ -532,6 +534,18 @@ public function countAllResults(bool $reset = true, bool $test = false) return $this->builder()->testMode($test)->countAllResults($reset); } + /** + * Explains the current Model query. + * + * @return BaseResult|false|Query|string Returns a SQL string if in test mode. + */ + public function explain(bool $reset = true, bool $test = false) + { + $this->prepareSoftDeleteQuery($reset); + + return $this->builder()->testMode($test)->explain($reset); + } + /** * Determines whether the current Model query would return at least one row. * diff --git a/tests/system/Database/Builder/ExplainTest.php b/tests/system/Database/Builder/ExplainTest.php new file mode 100644 index 000000000000..1de5a37c6611 --- /dev/null +++ b/tests/system/Database/Builder/ExplainTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Builder; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\OCI8\Builder as OCI8Builder; +use CodeIgniter\Database\SQLite3\Builder as SQLite3Builder; +use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class ExplainTest extends CIUnitTestCase +{ + protected $db; + + protected function setUp(): void + { + parent::setUp(); + + $this->db = new MockConnection([]); + } + + public function testExplainReturnsSqlInTestMode(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->explain(false); + + $expectedSQL = 'EXPLAIN SELECT * FROM "jobs" WHERE "id" > 3'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } + + public function testSQLiteExplainUsesQueryPlanInTestMode(): void + { + $builder = new SQLite3Builder('jobs', $this->db); + $builder->testMode(); + + $answer = $builder->where('id >', 3)->explain(false); + + $expectedSQL = 'EXPLAIN QUERY PLAN SELECT * FROM "jobs" WHERE "id" > 3'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $answer)); + } + + public function testExplainResetsByDefault(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->explain(); + + $this->assertSame('SELECT * FROM "jobs"', str_replace("\n", ' ', $builder->getCompiledSelect(false))); + $this->assertSame([], $builder->getBinds()); + } + + public function testExplainHonorsResetFalse(): void + { + $builder = new BaseBuilder('jobs', $this->db); + $builder->testMode(); + + $builder->where('id >', 3)->explain(false); + + $this->assertSame('SELECT * FROM "jobs" WHERE "id" > 3', str_replace("\n", ' ', $builder->getCompiledSelect(false))); + $this->assertSame([ + 'id' => [ + 3, + true, + ], + ], $builder->getBinds()); + } + + public function testExplainReturnsFalseWhenQueryFails(): void + { + $db = new MockConnection([]); + $db->shouldReturn('execute', false); + + $builder = new BaseBuilder('jobs', $db); + + $this->assertFalse($builder->where('id >', 3)->explain()); + $this->assertSame('SELECT * FROM "jobs"', str_replace("\n", ' ', $builder->getCompiledSelect(false))); + $this->assertSame([], $builder->getBinds()); + } + + public function testSQLSRVExplainIsNotSupported(): void + { + $builder = new SQLSRVBuilder('jobs', new MockConnection([ + 'DBDriver' => 'SQLSRV', + 'database' => 'test', + 'schema' => 'dbo', + ])); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support explain().'); + + $builder->explain(); + } + + public function testSQLSRVExplainChecksSupportBeforeCompilingSelect(): void + { + $db = new MockConnection([ + 'DBDriver' => 'SQLSRV', + 'database' => 'test', + 'schema' => 'dbo', + ]); + + $builder = new SQLSRVBuilder('jobs', $db); + $builder->union(new SQLSRVBuilder('jobs', $db))->lockForUpdate(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('SQLSRV does not support explain().'); + + $builder->explain(); + } + + public function testOCI8ExplainIsNotSupported(): void + { + $builder = new OCI8Builder('jobs', new MockConnection(['DBDriver' => 'OCI8'])); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('OCI8 does not support explain().'); + + $builder->explain(); + } +} diff --git a/tests/system/Database/Live/ExplainTest.php b/tests/system/Database/Live/ExplainTest.php new file mode 100644 index 000000000000..fdd1bbf457e9 --- /dev/null +++ b/tests/system/Database/Live/ExplainTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\ResultInterface; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExplainTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + public function testExplainReturnsResultForSupportedDrivers(): void + { + if (in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' does not support explain().'); + } + + $result = $this->db->table('job') + ->where('name', 'Developer') + ->explain(); + + $this->assertInstanceOf(ResultInterface::class, $result); + + $expectedPrefix = $this->db->DBDriver === 'SQLite3' + ? 'EXPLAIN QUERY PLAN SELECT' + : 'EXPLAIN SELECT'; + + $this->assertStringStartsWith( + $expectedPrefix, + str_replace("\n", ' ', (string) $this->db->getLastQuery()), + ); + } + + public function testExplainThrowsForUnsupportedDrivers(): void + { + if (! in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' supports explain().'); + } + + $this->expectException(DatabaseException::class); + + $this->db->table('job')->explain(); + } +} diff --git a/tests/system/Models/ExplainModelTest.php b/tests/system/Models/ExplainModelTest.php new file mode 100644 index 000000000000..e209722c43cb --- /dev/null +++ b/tests/system/Models/ExplainModelTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Models; + +use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Models\UserModel; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class ExplainModelTest extends LiveModelTestCase +{ + public function testExplainRespectsSoftDeletesInTestMode(): void + { + if (in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' does not support explain().'); + } + + $this->createModel(UserModel::class); + + $sql = $this->model->where('id', 1)->explain(test: true); + + $expectedPrefix = $this->db->DBDriver === 'SQLite3' + ? 'EXPLAIN QUERY PLAN SELECT' + : 'EXPLAIN SELECT'; + + $this->assertStringStartsWith($expectedPrefix, str_replace("\n", ' ', (string) $sql)); + $this->assertStringContainsString('deleted_at', (string) $sql); + } + + public function testExplainWithDeletedOmitsSoftDeleteConstraintInTestMode(): void + { + if (in_array($this->db->DBDriver, ['OCI8', 'SQLSRV'], true)) { + $this->markTestSkipped($this->db->DBDriver . ' does not support explain().'); + } + + $this->createModel(UserModel::class); + + $sql = $this->model->withDeleted()->where('id', 1)->explain(test: true); + + $this->assertStringNotContainsString('deleted_at', (string) $sql); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 896b2552aa83..78fe98c6a929 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -219,6 +219,7 @@ Query Builder ------------- - 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`. - Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`. - Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 36d04881e36c..a16b2a57b7f2 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -110,6 +110,28 @@ The key thing to notice in the above example is that the second query did not utilize ``limit(10, 20)`` but the generated SQL query has ``LIMIT 20, 10``. The reason for this outcome is because the parameter in the first query is set to ``false``, ``limit(10, 20)`` remained in the second query. +.. _query-builder-explain: + +$builder->explain() +------------------- + +.. versionadded:: 4.8.0 + +Runs an execution-plan query for the current Query Builder ``SELECT`` query: + +.. literalinclude:: query_builder/129.php + +This method returns a database result object. The result columns are driver-specific +because each database reports execution plans in its own format. +If test mode is enabled, it returns the compiled execution-plan SQL string. +If the query fails and ``DBDebug`` is ``false``, it returns ``false``. + +The method resets the current Query Builder state by default. If you need to keep +the current Query Builder state, you can pass ``false`` as the first parameter. + +This method is currently supported by MySQLi, Postgre, and SQLite3. SQLite3 uses +``EXPLAIN QUERY PLAN``. SQLSRV and OCI8 are not supported by this method. + $builder->getWhere() -------------------- @@ -1599,6 +1621,14 @@ Class Reference Generates a platform-specific query string that counts all records in the particular table. + .. php:method:: explain([$reset = true]) + + :param bool $reset: Whether to reset values for SELECTs + :returns: The execution-plan result, SQL string when test mode is enabled, or ``false`` on failure + :rtype: ResultInterface|false|string + + Runs an execution-plan query for the current Query Builder ``SELECT`` query. + .. php:method:: exists([$reset = true]) :param bool $reset: Whether to reset values for SELECTs diff --git a/user_guide_src/source/database/query_builder/129.php b/user_guide_src/source/database/query_builder/129.php new file mode 100644 index 000000000000..5f1244d93051 --- /dev/null +++ b/user_guide_src/source/database/query_builder/129.php @@ -0,0 +1,3 @@ +where('status', 'pending')->explain(); From 47e0a0c5ca32dc367e06e6b5b74f0920117d347c Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 25 May 2026 14:19:02 +0200 Subject: [PATCH 2/5] docs(database): note explain driver support Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- user_guide_src/source/database/query_builder.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index a16b2a57b7f2..2c771753c3ab 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -129,8 +129,9 @@ If the query fails and ``DBDebug`` is ``false``, it returns ``false``. The method resets the current Query Builder state by default. If you need to keep the current Query Builder state, you can pass ``false`` as the first parameter. -This method is currently supported by MySQLi, Postgre, and SQLite3. SQLite3 uses -``EXPLAIN QUERY PLAN``. SQLSRV and OCI8 are not supported by this method. +.. note:: This method is currently supported by MySQLi, Postgre, and SQLite3. + SQLite3 uses ``EXPLAIN QUERY PLAN``. SQLSRV and OCI8 are not supported by + this method. $builder->getWhere() -------------------- From d015b45b61c4a3bd94a341d1b86cf66a5d14cbef Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 25 May 2026 14:27:35 +0200 Subject: [PATCH 3/5] retrigger CI Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> From 89dbaf6313dbbf1ff5379959cc3174056e2f8f0c Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 26 May 2026 12:25:51 +0200 Subject: [PATCH 4/5] refactor(database): use never for unsupported explain drivers Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/OCI8/Builder.php | 2 +- system/Database/SQLSRV/Builder.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 11ce3b46b9f5..54eafe6098de 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -236,7 +236,7 @@ protected function compileLockForUpdate(): string /** * Ensures the current driver supports explaining Query Builder selects. */ - protected function assertExplainSupported(): void + protected function assertExplainSupported(): never { throw new DatabaseException('OCI8 does not support explain().'); } diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 9f8b32791bd7..f484e58dfd9b 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -719,7 +719,7 @@ protected function compileLockForUpdate(): string /** * Ensures the current driver supports explaining Query Builder selects. */ - protected function assertExplainSupported(): void + protected function assertExplainSupported(): never { throw new DatabaseException('SQLSRV does not support explain().'); } From d405fb7196d482c3879692fa68bbd3ce7a7f08d1 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 26 May 2026 12:45:08 +0200 Subject: [PATCH 5/5] retrigger CI Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>