diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index d5bb40bebd0a..f2ee6b3ed6a4 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/_support/Mock/MockPreparedQuery.php b/tests/_support/Mock/MockPreparedQuery.php new file mode 100644 index 000000000000..c31dc266d84d --- /dev/null +++ b/tests/_support/Mock/MockPreparedQuery.php @@ -0,0 +1,54 @@ + + * + * 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 + * + * @extends BasePreparedQuery + */ +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 new file mode 100644 index 000000000000..a7830cd2853c --- /dev/null +++ b/tests/system/Database/BasePreparedQueryTest.php @@ -0,0 +1,61 @@ + + * + * 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; +use Tests\Support\Mock\MockPreparedQuery; + +/** + * @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(): MockPreparedQuery + { + return new MockPreparedQuery(new MockConnection([])); + } +} diff --git a/tests/system/Database/Live/PreparedQueryTest.php b/tests/system/Database/Live/PreparedQueryTest.php index c66202c191af..304269969a2d 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 instanceof BasePreparedQuery) { + return; + } + try { $this->query->close(); } catch (BadMethodCallException) { @@ -109,6 +113,70 @@ public function testPrepareReturnsManualPreparedQuery(): void $this->assertSame($expected, $this->query->getQueryString()); } + public function testPrepareAndExecuteManualQueryWithNamedPlaceholdersKeepsTimeLiteral(): void + { + // 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 " . $timeValue . ' ' + . '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 " . $timeValue, $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.