Skip to content

Commit efe179f

Browse files
authored
fix: preserve Postgre casts when converting named placeholders in prepared queries (#9960)
* fix: preserve Postgre casts when converting named placeholders in prepared queries * refactor tests * fix psalm
1 parent 6ee78ae commit efe179f

File tree

5 files changed

+188
-4
lines changed

5 files changed

+188
-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_]\w*):?(?!:)/', '?', $sql);
8787

8888
/** @var Query $query */
8989
$query = new $queryClass($this->db);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 Tests\Support\Mock;
15+
16+
use CodeIgniter\Database\BasePreparedQuery;
17+
18+
/**
19+
* @internal
20+
*
21+
* @extends BasePreparedQuery<object, object, object>
22+
*/
23+
final class MockPreparedQuery extends BasePreparedQuery
24+
{
25+
public string $preparedSql = '';
26+
27+
/**
28+
* @param array<string, mixed> $options
29+
*/
30+
public function _prepare(string $sql, array $options = []): self
31+
{
32+
$this->preparedSql = $sql;
33+
34+
return $this;
35+
}
36+
37+
/**
38+
* @param array<int, mixed> $data
39+
*/
40+
public function _execute(array $data): bool
41+
{
42+
return true;
43+
}
44+
45+
public function _getResult()
46+
{
47+
return null;
48+
}
49+
50+
protected function _close(): bool
51+
{
52+
return true;
53+
}
54+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
use Tests\Support\Mock\MockPreparedQuery;
20+
21+
/**
22+
* @internal
23+
*/
24+
#[Group('Others')]
25+
final class BasePreparedQueryTest extends CIUnitTestCase
26+
{
27+
public function testPrepareConvertsNamedPlaceholdersToPositionalPlaceholders(): void
28+
{
29+
$query = $this->createPreparedQuery();
30+
31+
$query->prepare('SELECT * FROM users WHERE id = :id: AND name = :name');
32+
33+
$this->assertSame('SELECT * FROM users WHERE id = ? AND name = ?', $query->preparedSql);
34+
}
35+
36+
public function testPrepareDoesNotConvertPostgreStyleCastSyntax(): void
37+
{
38+
$query = $this->createPreparedQuery();
39+
40+
$query->prepare('SELECT :name: AS name, created_at::timestamp AS created FROM users WHERE id = :id:');
41+
42+
$this->assertSame(
43+
'SELECT ? AS name, created_at::timestamp AS created FROM users WHERE id = ?',
44+
$query->preparedSql,
45+
);
46+
}
47+
48+
public function testPrepareDoesNotConvertTimeLikeLiterals(): void
49+
{
50+
$query = $this->createPreparedQuery();
51+
52+
$query->prepare("SELECT '12:34' AS time_value, :id: AS id");
53+
54+
$this->assertSame("SELECT '12:34' AS time_value, ? AS id", $query->preparedSql);
55+
}
56+
57+
private function createPreparedQuery(): MockPreparedQuery
58+
{
59+
return new MockPreparedQuery(new MockConnection([]));
60+
}
61+
}

tests/system/Database/Live/PreparedQueryTest.php

Lines changed: 68 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 instanceof BasePreparedQuery) {
49+
return;
50+
}
51+
4852
try {
4953
$this->query->close();
5054
} catch (BadMethodCallException) {
@@ -109,6 +113,70 @@ public function testPrepareReturnsManualPreparedQuery(): void
109113
$this->assertSame($expected, $this->query->getQueryString());
110114
}
111115

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