Skip to content

Commit f1e8bdd

Browse files
committed
Merge remote-tracking branch 'upstream/develop' into 4.8
2 parents 5c78ba2 + 54ba38c commit f1e8bdd

File tree

11 files changed

+260
-47
lines changed

11 files changed

+260
-47
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"phpunit/phpcov": "^9.0.2 || ^10.0",
2929
"phpunit/phpunit": "^10.5.16 || ^11.2",
3030
"predis/predis": "^3.0",
31-
"rector/rector": "2.3.6",
31+
"rector/rector": "2.3.7",
3232
"shipmonk/phpstan-baseline-per-identifier": "^2.0"
3333
},
3434
"replace": {

system/BaseModel.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ abstract public function countAllResults(bool $reset = true, bool $test = false)
582582
* @return void
583583
*
584584
* @throws DataException
585+
* @throws InvalidArgumentException if $size is not a positive integer
585586
*/
586587
abstract public function chunk(int $size, Closure $userFunc);
587588

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);

system/Debug/Exceptions.php

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,6 @@ public function errorHandler(int $severity, string $message, ?string $file = nul
201201
return true;
202202
}
203203

204-
if ($this->isImplicitNullableDeprecationError($message, $file, $line)) {
205-
return true;
206-
}
207-
208204
if (! $this->config->logDeprecations || (bool) env('CODEIGNITER_SCREAM_DEPRECATIONS')) {
209205
throw new ErrorException($message, 0, $severity, $file, $line);
210206
}
@@ -245,38 +241,6 @@ private function isSessionSidDeprecationError(string $message, ?string $file = n
245241
return false;
246242
}
247243

248-
/**
249-
* Workaround to implicit nullable deprecation errors in PHP 8.4.
250-
*
251-
* "Implicitly marking parameter $xxx as nullable is deprecated,
252-
* the explicit nullable type must be used instead"
253-
*
254-
* @TODO remove this before v4.6.0 release
255-
*/
256-
private function isImplicitNullableDeprecationError(string $message, ?string $file = null, ?int $line = null): bool
257-
{
258-
if (
259-
PHP_VERSION_ID >= 80400
260-
&& str_contains($message, 'the explicit nullable type must be used instead')
261-
// Only Kint and Faker, which cause this error, are logged.
262-
&& (str_starts_with($message, 'Kint\\') || str_starts_with($message, 'Faker\\'))
263-
) {
264-
log_message(
265-
LogLevel::WARNING,
266-
'[DEPRECATED] {message} in {errFile} on line {errLine}.',
267-
[
268-
'message' => $message,
269-
'errFile' => clean_path($file ?? ''),
270-
'errLine' => $line ?? 0,
271-
],
272-
);
273-
274-
return true;
275-
}
276-
277-
return false;
278-
}
279-
280244
/**
281245
* Checks to see if any errors have happened during shutdown that
282246
* need to be caught and handle them.

system/Debug/Toolbar/Views/toolbar.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ var ciDebugBar = {
762762
var rowGet = this.toolbar.querySelectorAll(
763763
'td[data-debugbar-route="GET"]'
764764
);
765-
var patt = /\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)/;
765+
var patt = /\(.+?\)/g;
766766

767767
for (var i = 0; i < rowGet.length; i++) {
768768
row = rowGet[i];
@@ -788,10 +788,9 @@ var ciDebugBar = {
788788
'<form data-debugbar-route-tpl="' +
789789
ciDebugBar.trimSlash(row.innerText.replace(patt, "?")) +
790790
'">' +
791-
row.innerText.replace(
792-
patt,
793-
'<input id="debugbar-route-id-' + i + '" type="text" placeholder="$1">'
794-
) +
791+
row.innerText.replace(patt, function (match) {
792+
return '<input type="text" placeholder="' + match + '">';
793+
}) +
795794
'<input type="submit" value="Go" class="debug-bar-mleft4">' +
796795
"</form>";
797796
}

system/Model.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use CodeIgniter\Database\Exceptions\DataException;
2222
use CodeIgniter\Entity\Entity;
2323
use CodeIgniter\Exceptions\BadMethodCallException;
24+
use CodeIgniter\Exceptions\InvalidArgumentException;
2425
use CodeIgniter\Exceptions\ModelException;
2526
use CodeIgniter\Validation\ValidationInterface;
2627
use Config\Database;
@@ -533,10 +534,14 @@ public function countAllResults(bool $reset = true, bool $test = false)
533534
*/
534535
public function chunk(int $size, Closure $userFunc)
535536
{
537+
if ($size <= 0) {
538+
throw new InvalidArgumentException('chunk() requires a positive integer for the $size argument.');
539+
}
540+
536541
$total = $this->builder()->countAllResults(false);
537542
$offset = 0;
538543

539-
while ($offset <= $total) {
544+
while ($offset < $total) {
540545
$builder = clone $this->builder();
541546
$rows = $builder->get($size, $offset);
542547

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([

tests/system/Models/MiscellaneousModelTest.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
namespace CodeIgniter\Models;
1515

1616
use CodeIgniter\Database\Exceptions\DataException;
17+
use CodeIgniter\Events\Events;
18+
use CodeIgniter\Exceptions\InvalidArgumentException;
1719
use CodeIgniter\I18n\Time;
1820
use PHPUnit\Framework\Attributes\Group;
1921
use Tests\Support\Models\EntityModel;
@@ -39,6 +41,62 @@ public function testChunk(): void
3941
$this->assertSame(4, $rowCount);
4042
}
4143

44+
public function testChunkThrowsOnZeroSize(): void
45+
{
46+
$this->expectException(InvalidArgumentException::class);
47+
$this->expectExceptionMessage('chunk() requires a positive integer for the $size argument.');
48+
49+
$this->createModel(UserModel::class)->chunk(0, static function ($row): void {});
50+
}
51+
52+
public function testChunkThrowsOnNegativeSize(): void
53+
{
54+
$this->expectException(InvalidArgumentException::class);
55+
$this->expectExceptionMessage('chunk() requires a positive integer for the $size argument.');
56+
57+
$this->createModel(UserModel::class)->chunk(-1, static function ($row): void {});
58+
}
59+
60+
public function testChunkEarlyExit(): void
61+
{
62+
$rowCount = 0;
63+
64+
$this->createModel(UserModel::class)->chunk(2, static function ($row) use (&$rowCount): bool {
65+
$rowCount++;
66+
67+
return false;
68+
});
69+
70+
$this->assertSame(1, $rowCount);
71+
}
72+
73+
public function testChunkDoesNotRunExtraQuery(): void
74+
{
75+
$queryCount = 0;
76+
$listener = static function () use (&$queryCount): void {
77+
$queryCount++;
78+
};
79+
80+
Events::on('DBQuery', $listener);
81+
$this->createModel(UserModel::class)->chunk(4, static function ($row): void {});
82+
Events::removeListener('DBQuery', $listener);
83+
84+
$this->assertSame(2, $queryCount);
85+
}
86+
87+
public function testChunkEmptyTable(): void
88+
{
89+
$this->db->table('user')->truncate();
90+
91+
$rowCount = 0;
92+
93+
$this->createModel(UserModel::class)->chunk(2, static function ($row) use (&$rowCount): void {
94+
$rowCount++;
95+
});
96+
97+
$this->assertSame(0, $rowCount);
98+
}
99+
42100
public function testCanCreateAndSaveEntityClasses(): void
43101
{
44102
$entity = $this->createModel(EntityModel::class)->where('name', 'Developer')->first();

0 commit comments

Comments
 (0)