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
8 changes: 4 additions & 4 deletions system/Database/BasePreparedQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('/(?<!:):([a-zA-Z_]\w*):?(?!:)/', '?', $sql);

/** @var Query $query */
$query = new $queryClass($this->db);
Expand Down
54 changes: 54 additions & 0 deletions tests/_support/Mock/MockPreparedQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?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 Tests\Support\Mock;

use CodeIgniter\Database\BasePreparedQuery;

/**
* @internal
*
* @extends BasePreparedQuery<object, object, object>
*/
final class MockPreparedQuery extends BasePreparedQuery
{
public string $preparedSql = '';

/**
* @param array<string, mixed> $options
*/
public function _prepare(string $sql, array $options = []): self
{
$this->preparedSql = $sql;

return $this;
}

/**
* @param array<int, mixed> $data
*/
public function _execute(array $data): bool
{
return true;
}

public function _getResult()
{
return null;
}

protected function _close(): bool
{
return true;
}
}
61 changes: 61 additions & 0 deletions tests/system/Database/BasePreparedQueryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?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;

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([]));
}
}
68 changes: 68 additions & 0 deletions tests/system/Database/Live/PreparedQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ protected function tearDown(): void
{
parent::tearDown();

if (! $this->query instanceof BasePreparedQuery) {
return;
}

try {
$this->query->close();
} catch (BadMethodCallException) {
Expand Down Expand Up @@ -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([
Expand Down
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.7.1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading