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
41 changes: 41 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions system/Database/OCI8/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
8 changes: 8 additions & 0 deletions system/Database/SQLSRV/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions system/Database/SQLite3/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
14 changes: 14 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
143 changes: 143 additions & 0 deletions tests/system/Database/Builder/ExplainTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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();
}
}
66 changes: 66 additions & 0 deletions tests/system/Database/Live/ExplainTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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();
}
}
55 changes: 55 additions & 0 deletions tests/system/Models/ExplainModelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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);
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading