From a34d65340e29d0b2afa214f1b93ff5bfafe71279 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 18 Feb 2026 09:31:17 +0100 Subject: [PATCH 1/3] fix: preserve Postgre casts when converting named placeholders in prepared queries --- system/Database/BasePreparedQuery.php | 8 +- .../system/Database/BasePreparedQueryTest.php | 84 +++++++++++++++++++ .../Database/Live/PreparedQueryTest.php | 66 +++++++++++++++ user_guide_src/source/changelogs/v4.7.1.rst | 1 + 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 tests/system/Database/BasePreparedQueryTest.php diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index d5bb40bebd0a..e64b47d4fbaf 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -80,10 +80,10 @@ public function __construct(BaseConnection $db) */ public function prepare(string $sql, array $options = [], string $queryClass = Query::class) { - // We only supports positional placeholders (?) - // in order to work with the execute method below, so we - // need to replace our named placeholders (:name) - $sql = preg_replace('/:[^\s,)]+/', '?', $sql); + // We only support positional placeholders (?), so convert + // named placeholders (:name or :name:) while leaving dialect + // syntax like PostgreSQL casts (::type) untouched. + $sql = preg_replace('/(?db); diff --git a/tests/system/Database/BasePreparedQueryTest.php b/tests/system/Database/BasePreparedQueryTest.php new file mode 100644 index 000000000000..b2adb4e112f1 --- /dev/null +++ b/tests/system/Database/BasePreparedQueryTest.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class BasePreparedQueryTest extends CIUnitTestCase +{ + public function testPrepareConvertsNamedPlaceholdersToPositionalPlaceholders(): void + { + $query = $this->createPreparedQuery(); + + $query->prepare('SELECT * FROM users WHERE id = :id: AND name = :name'); + + $this->assertSame('SELECT * FROM users WHERE id = ? AND name = ?', $query->preparedSql); + } + + public function testPrepareDoesNotConvertPostgreStyleCastSyntax(): void + { + $query = $this->createPreparedQuery(); + + $query->prepare('SELECT :name: AS name, created_at::timestamp AS created FROM users WHERE id = :id:'); + + $this->assertSame( + 'SELECT ? AS name, created_at::timestamp AS created FROM users WHERE id = ?', + $query->preparedSql, + ); + } + + public function testPrepareDoesNotConvertTimeLikeLiterals(): void + { + $query = $this->createPreparedQuery(); + + $query->prepare("SELECT '12:34' AS time_value, :id: AS id"); + + $this->assertSame("SELECT '12:34' AS time_value, ? AS id", $query->preparedSql); + } + + private function createPreparedQuery(): BasePreparedQuery + { + return new class (new MockConnection([])) extends BasePreparedQuery { + public string $preparedSql = ''; + + public function _prepare(string $sql, array $options = []) + { + $this->preparedSql = $sql; + + return $this; + } + + public function _execute(array $data): bool + { + return true; + } + + public function _getResult() + { + return null; + } + + protected function _close(): bool + { + return true; + } + }; + } +} diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index c66202c191af..df6cbfe0016a 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -45,6 +45,10 @@ protected function tearDown(): void { parent::tearDown(); + if ($this->query === null) { + return; + } + try { $this->query->close(); } catch (BadMethodCallException) { @@ -109,6 +113,68 @@ public function testPrepareReturnsManualPreparedQuery(): void $this->assertSame($expected, $this->query->getQueryString()); } + public function testPrepareAndExecuteManualQueryWithNamedPlaceholdersKeepsTimeLiteral(): void + { + $this->query = $this->db->prepare(static function ($db): Query { + $sql = 'SELECT ' + . $db->protectIdentifiers('name') . ', ' + . $db->protectIdentifiers('email') + . ", '12:34' AS time_value " + . 'FROM ' . $db->protectIdentifiers($db->DBPrefix . 'user') + . ' WHERE ' + . $db->protectIdentifiers('name') . ' = :name:' + . ' AND ' . $db->protectIdentifiers('email') . ' = :email'; + + return (new Query($db))->setQuery($sql); + }); + + $preparedSql = $this->query->getQueryString(); + + $this->assertStringContainsString("'12:34' AS time_value", $preparedSql); + + if ($this->db->DBDriver === 'Postgre') { + $this->assertStringContainsString(' = $1', $preparedSql); + $this->assertStringContainsString(' = $2', $preparedSql); + } else { + $this->assertStringContainsString(' = ?', $preparedSql); + } + + $result = $this->query->execute('Derek Jones', 'derek@world.com'); + + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertSame('Derek Jones', $result->getRow()->name); + $this->assertSame('derek@world.com', $result->getRow()->email); + $this->assertSame('12:34', $result->getRow()->time_value); + } + + public function testPrepareAndExecuteManualQueryWithPostgreCastKeepsDoubleColonSyntax(): void + { + if ($this->db->DBDriver !== 'Postgre') { + $this->markTestSkipped('PostgreSQL-specific cast syntax test.'); + } + + $this->query = $this->db->prepare(static function ($db): Query { + $sql = 'SELECT ' + . ':value: AS value, now()::timestamp AS created_at' + . ' FROM ' . $db->protectIdentifiers($db->DBPrefix . 'user') + . ' WHERE ' . $db->protectIdentifiers('name') . ' = :name:'; + + return (new Query($db))->setQuery($sql); + }); + + $preparedSql = $this->query->getQueryString(); + + $this->assertStringContainsString('$1 AS value', $preparedSql); + $this->assertStringContainsString('now()::timestamp AS created_at', $preparedSql); + + $result = $this->query->execute('ci4', 'Derek Jones'); + + $this->assertInstanceOf(ResultInterface::class, $result); + $this->assertSame('ci4', $result->getRow()->value); + $this->assertNotEmpty($result->getRow()->created_at); + $this->assertNotSame('now()::timestamp', $result->getRow()->created_at); + } + public function testExecuteRunsQueryAndReturnsTrue(): void { $this->query = $this->db->prepare(static fn ($db) => $db->table('user')->insert([ diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 14445ae29577..2a7f94333320 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -41,6 +41,7 @@ Bugs Fixed - **ContentSecurityPolicy:** Fixed a bug where custom CSP tags were not removed from generated HTML when CSP was disabled. The method now ensures that all custom CSP tags are removed from the generated HTML. - **ContentSecurityPolicy:** Fixed a bug where ``generateNonces()`` produces corrupted JSON responses by replacing CSP nonce placeholders with unescaped double quotes. The method now automatically JSON-escapes nonce attributes when the response Content-Type is JSON. - **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. +- **Database:** Fixed a bug where ``BasePreparedQuery::prepare()`` could mis-handle SQL containing colon syntax by over-broad named-placeholder replacement. It now preserves PostgreSQL cast syntax like ``::timestamp``. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. From 5aacf798b12a12ef67186ffbbd42b4668dbd33ed Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 18 Feb 2026 15:58:36 +0100 Subject: [PATCH 2/3] refactor tests --- system/Database/BasePreparedQuery.php | 2 +- tests/_support/Mock/MockPreparedQuery.php | 52 +++++++++++++++++++ .../system/Database/BasePreparedQueryTest.php | 29 ++--------- .../Database/Live/PreparedQueryTest.php | 10 ++-- 4 files changed, 62 insertions(+), 31 deletions(-) create mode 100644 tests/_support/Mock/MockPreparedQuery.php diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index e64b47d4fbaf..f2ee6b3ed6a4 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -83,7 +83,7 @@ public function prepare(string $sql, array $options = [], string $queryClass = Q // We only support positional placeholders (?), so convert // named placeholders (:name or :name:) while leaving dialect // syntax like PostgreSQL casts (::type) untouched. - $sql = preg_replace('/(?db); diff --git a/tests/_support/Mock/MockPreparedQuery.php b/tests/_support/Mock/MockPreparedQuery.php new file mode 100644 index 000000000000..bc351844fde5 --- /dev/null +++ b/tests/_support/Mock/MockPreparedQuery.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Mock; + +use CodeIgniter\Database\BasePreparedQuery; + +/** + * @internal + */ +final class MockPreparedQuery extends BasePreparedQuery +{ + public string $preparedSql = ''; + + /** + * @param array $options + */ + public function _prepare(string $sql, array $options = []): self + { + $this->preparedSql = $sql; + + return $this; + } + + /** + * @param array $data + */ + public function _execute(array $data): bool + { + return true; + } + + public function _getResult() + { + return null; + } + + protected function _close(): bool + { + return true; + } +} diff --git a/tests/system/Database/BasePreparedQueryTest.php b/tests/system/Database/BasePreparedQueryTest.php index b2adb4e112f1..a7830cd2853c 100644 --- a/tests/system/Database/BasePreparedQueryTest.php +++ b/tests/system/Database/BasePreparedQueryTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Mock\MockPreparedQuery; /** * @internal @@ -53,32 +54,8 @@ public function testPrepareDoesNotConvertTimeLikeLiterals(): void $this->assertSame("SELECT '12:34' AS time_value, ? AS id", $query->preparedSql); } - private function createPreparedQuery(): BasePreparedQuery + private function createPreparedQuery(): MockPreparedQuery { - return new class (new MockConnection([])) extends BasePreparedQuery { - public string $preparedSql = ''; - - public function _prepare(string $sql, array $options = []) - { - $this->preparedSql = $sql; - - return $this; - } - - public function _execute(array $data): bool - { - return true; - } - - public function _getResult() - { - return null; - } - - protected function _close(): bool - { - return true; - } - }; + return new MockPreparedQuery(new MockConnection([])); } } diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index df6cbfe0016a..304269969a2d 100644 --- a/tests/system/Database/Live/PreparedQueryTest.php +++ b/tests/system/Database/Live/PreparedQueryTest.php @@ -45,7 +45,7 @@ protected function tearDown(): void { parent::tearDown(); - if ($this->query === null) { + if (! $this->query instanceof BasePreparedQuery) { return; } @@ -115,11 +115,13 @@ public function testPrepareReturnsManualPreparedQuery(): void public function testPrepareAndExecuteManualQueryWithNamedPlaceholdersKeepsTimeLiteral(): void { - $this->query = $this->db->prepare(static function ($db): Query { + // Quote alias to keep a consistent property name across drivers (OCI8 uppercases unquoted aliases) + $timeValue = $this->db->protectIdentifiers('time_value'); + $this->query = $this->db->prepare(static function ($db) use ($timeValue): Query { $sql = 'SELECT ' . $db->protectIdentifiers('name') . ', ' . $db->protectIdentifiers('email') - . ", '12:34' AS time_value " + . ", '12:34' AS " . $timeValue . ' ' . 'FROM ' . $db->protectIdentifiers($db->DBPrefix . 'user') . ' WHERE ' . $db->protectIdentifiers('name') . ' = :name:' @@ -130,7 +132,7 @@ public function testPrepareAndExecuteManualQueryWithNamedPlaceholdersKeepsTimeLi $preparedSql = $this->query->getQueryString(); - $this->assertStringContainsString("'12:34' AS time_value", $preparedSql); + $this->assertStringContainsString("'12:34' AS " . $timeValue, $preparedSql); if ($this->db->DBDriver === 'Postgre') { $this->assertStringContainsString(' = $1', $preparedSql); From 2bbeece207216eaf5e49fe9022d7a24f56fd1e16 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 18 Feb 2026 16:09:30 +0100 Subject: [PATCH 3/3] fix psalm --- tests/_support/Mock/MockPreparedQuery.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/_support/Mock/MockPreparedQuery.php b/tests/_support/Mock/MockPreparedQuery.php index bc351844fde5..c31dc266d84d 100644 --- a/tests/_support/Mock/MockPreparedQuery.php +++ b/tests/_support/Mock/MockPreparedQuery.php @@ -17,6 +17,8 @@ /** * @internal + * + * @extends BasePreparedQuery */ final class MockPreparedQuery extends BasePreparedQuery {