Skip to content

Commit a34d653

Browse files
committed
fix: preserve Postgre casts when converting named placeholders in prepared queries
1 parent c0c7792 commit a34d653

File tree

4 files changed

+155
-4
lines changed

4 files changed

+155
-4
lines changed

system/Database/BasePreparedQuery.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,10 @@ public function __construct(BaseConnection $db)
8080
*/
8181
public function prepare(string $sql, array $options = [], string $queryClass = Query::class)
8282
{
83-
// We only supports positional placeholders (?)
84-
// in order to work with the execute method below, so we
85-
// need to replace our named placeholders (:name)
86-
$sql = preg_replace('/:[^\s,)]+/', '?', $sql);
83+
// We only support positional placeholders (?), so convert
84+
// named placeholders (:name or :name:) while leaving dialect
85+
// syntax like PostgreSQL casts (::type) untouched.
86+
$sql = preg_replace('/(?<!:):([a-zA-Z_][a-zA-Z0-9_]*):?(?!:)/', '?', $sql);
8787

8888
/** @var Query $query */
8989
$query = new $queryClass($this->db);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Database;
15+
16+
use CodeIgniter\Test\CIUnitTestCase;
17+
use CodeIgniter\Test\Mock\MockConnection;
18+
use PHPUnit\Framework\Attributes\Group;
19+
20+
/**
21+
* @internal
22+
*/
23+
#[Group('Others')]
24+
final class BasePreparedQueryTest extends CIUnitTestCase
25+
{
26+
public function testPrepareConvertsNamedPlaceholdersToPositionalPlaceholders(): void
27+
{
28+
$query = $this->createPreparedQuery();
29+
30+
$query->prepare('SELECT * FROM users WHERE id = :id: AND name = :name');
31+
32+
$this->assertSame('SELECT * FROM users WHERE id = ? AND name = ?', $query->preparedSql);
33+
}
34+
35+
public function testPrepareDoesNotConvertPostgreStyleCastSyntax(): void
36+
{
37+
$query = $this->createPreparedQuery();
38+
39+
$query->prepare('SELECT :name: AS name, created_at::timestamp AS created FROM users WHERE id = :id:');
40+
41+
$this->assertSame(
42+
'SELECT ? AS name, created_at::timestamp AS created FROM users WHERE id = ?',
43+
$query->preparedSql,
44+
);
45+
}
46+
47+
public function testPrepareDoesNotConvertTimeLikeLiterals(): void
48+
{
49+
$query = $this->createPreparedQuery();
50+
51+
$query->prepare("SELECT '12:34' AS time_value, :id: AS id");
52+
53+
$this->assertSame("SELECT '12:34' AS time_value, ? AS id", $query->preparedSql);
54+
}
55+
56+
private function createPreparedQuery(): BasePreparedQuery
57+
{
58+
return new class (new MockConnection([])) extends BasePreparedQuery {
59+
public string $preparedSql = '';
60+
61+
public function _prepare(string $sql, array $options = [])
62+
{
63+
$this->preparedSql = $sql;
64+
65+
return $this;
66+
}
67+
68+
public function _execute(array $data): bool
69+
{
70+
return true;
71+
}
72+
73+
public function _getResult()
74+
{
75+
return null;
76+
}
77+
78+
protected function _close(): bool
79+
{
80+
return true;
81+
}
82+
};
83+
}
84+
}

tests/system/Database/Live/PreparedQueryTest.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ protected function tearDown(): void
4545
{
4646
parent::tearDown();
4747

48+
if ($this->query === null) {
49+
return;
50+
}
51+
4852
try {
4953
$this->query->close();
5054
} catch (BadMethodCallException) {
@@ -109,6 +113,68 @@ public function testPrepareReturnsManualPreparedQuery(): void
109113
$this->assertSame($expected, $this->query->getQueryString());
110114
}
111115

116+
public function testPrepareAndExecuteManualQueryWithNamedPlaceholdersKeepsTimeLiteral(): void
117+
{
118+
$this->query = $this->db->prepare(static function ($db): Query {
119+
$sql = 'SELECT '
120+
. $db->protectIdentifiers('name') . ', '
121+
. $db->protectIdentifiers('email')
122+
. ", '12:34' AS time_value "
123+
. 'FROM ' . $db->protectIdentifiers($db->DBPrefix . 'user')
124+
. ' WHERE '
125+
. $db->protectIdentifiers('name') . ' = :name:'
126+
. ' AND ' . $db->protectIdentifiers('email') . ' = :email';
127+
128+
return (new Query($db))->setQuery($sql);
129+
});
130+
131+
$preparedSql = $this->query->getQueryString();
132+
133+
$this->assertStringContainsString("'12:34' AS time_value", $preparedSql);
134+
135+
if ($this->db->DBDriver === 'Postgre') {
136+
$this->assertStringContainsString(' = $1', $preparedSql);
137+
$this->assertStringContainsString(' = $2', $preparedSql);
138+
} else {
139+
$this->assertStringContainsString(' = ?', $preparedSql);
140+
}
141+
142+
$result = $this->query->execute('Derek Jones', 'derek@world.com');
143+
144+
$this->assertInstanceOf(ResultInterface::class, $result);
145+
$this->assertSame('Derek Jones', $result->getRow()->name);
146+
$this->assertSame('derek@world.com', $result->getRow()->email);
147+
$this->assertSame('12:34', $result->getRow()->time_value);
148+
}
149+
150+
public function testPrepareAndExecuteManualQueryWithPostgreCastKeepsDoubleColonSyntax(): void
151+
{
152+
if ($this->db->DBDriver !== 'Postgre') {
153+
$this->markTestSkipped('PostgreSQL-specific cast syntax test.');
154+
}
155+
156+
$this->query = $this->db->prepare(static function ($db): Query {
157+
$sql = 'SELECT '
158+
. ':value: AS value, now()::timestamp AS created_at'
159+
. ' FROM ' . $db->protectIdentifiers($db->DBPrefix . 'user')
160+
. ' WHERE ' . $db->protectIdentifiers('name') . ' = :name:';
161+
162+
return (new Query($db))->setQuery($sql);
163+
});
164+
165+
$preparedSql = $this->query->getQueryString();
166+
167+
$this->assertStringContainsString('$1 AS value', $preparedSql);
168+
$this->assertStringContainsString('now()::timestamp AS created_at', $preparedSql);
169+
170+
$result = $this->query->execute('ci4', 'Derek Jones');
171+
172+
$this->assertInstanceOf(ResultInterface::class, $result);
173+
$this->assertSame('ci4', $result->getRow()->value);
174+
$this->assertNotEmpty($result->getRow()->created_at);
175+
$this->assertNotSame('now()::timestamp', $result->getRow()->created_at);
176+
}
177+
112178
public function testExecuteRunsQueryAndReturnsTrue(): void
113179
{
114180
$this->query = $this->db->prepare(static fn ($db) => $db->table('user')->insert([

user_guide_src/source/changelogs/v4.7.1.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Bugs Fixed
4141
- **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.
4242
- **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.
4343
- **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names.
44+
- **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``.
4445
- **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change.
4546
- **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty.
4647
- **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive.

0 commit comments

Comments
 (0)