Skip to content

Commit e711ab0

Browse files
authored
Fix numeric compare affinity for SQLite (#989)
1 parent 94fc1b1 commit e711ab0

File tree

7 files changed

+115
-24
lines changed

7 files changed

+115
-24
lines changed

phpstan.neon.dist

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ parameters:
4040
-
4141
message: '~^Class Doctrine\\DBAL\\Platforms\\SqlitePlatform referenced with incorrect case: Doctrine\\DBAL\\Platforms\\SQLitePlatform\.$~'
4242
path: '*'
43-
count: 28
43+
count: 25
4444

4545
# TODO these rules are generated, this ignores should be fixed in the code
4646
# for src/Schema/TestCase.php

src/Persistence/Sql/Query.php

+20-2
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,24 @@ protected function _subrenderWhere($kind): array
501501
return $res;
502502
}
503503

504+
/**
505+
* Override to fix numeric affinity for SQLite.
506+
*/
507+
protected function _renderConditionBinary(string $operator, string $sqlLeft, string $sqlRight): string
508+
{
509+
return $sqlLeft . ' ' . $operator . ' ' . $sqlRight;
510+
}
511+
512+
/**
513+
* Override to fix numeric affinity for SQLite.
514+
*
515+
* @param non-empty-list<string> $sqlValues
516+
*/
517+
protected function _renderConditionInOperator(bool $negated, string $sqlLeft, array $sqlValues): string
518+
{
519+
return $sqlLeft . ($negated ? ' not' : '') . ' in (' . implode(', ', $sqlValues) . ')';
520+
}
521+
504522
/**
505523
* @param array<0|1|2, mixed> $row
506524
*/
@@ -575,7 +593,7 @@ protected function _subrenderCondition(array $row): string
575593

576594
$values = array_map(fn ($v) => $this->consume($v, self::ESCAPE_PARAM), $value);
577595

578-
return $field . ' ' . $cond . ' (' . implode(', ', $values) . ')';
596+
return $this->_renderConditionInOperator($cond === 'not in', $field, $values);
579597
}
580598

581599
throw (new Exception('Unsupported operator for array value'))
@@ -589,7 +607,7 @@ protected function _subrenderCondition(array $row): string
589607
// otherwise just escape value
590608
$value = $this->consume($value, self::ESCAPE_PARAM);
591609

592-
return $field . ' ' . $cond . ' ' . $value;
610+
return $this->_renderConditionBinary($cond, $field, $value);
593611
}
594612

595613
protected function _renderWhere(): ?string

src/Persistence/Sql/Sqlite/Query.php

+51
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,57 @@ class Query extends BaseQuery
1515

1616
protected string $templateTruncate = 'delete [from] [tableNoalias]';
1717

18+
private function _renderConditionBinaryCheckNumericSql(string $sql): string
19+
{
20+
return 'typeof(' . $sql . ') in (\'integer\', \'real\')';
21+
}
22+
23+
/**
24+
* https://dba.stackexchange.com/questions/332585/sqlite-comparison-of-the-same-operand-types-behaves-differently
25+
* https://sqlite.org/forum/forumpost/5f1135146fbc37ab .
26+
*/
27+
protected function _renderConditionBinary(string $operator, string $sqlLeft, string $sqlRight): string
28+
{
29+
// TODO deduplicate the duplicated SQL using https://sqlite.org/forum/info/c9970a37edf11cd1
30+
// https://github.com/sqlite/sqlite/commit/5e4233a9e48b124d4d342b757b34e4ae849f5cf8
31+
// expected to be supported since SQLite v3.45.0
32+
33+
/** @var bool */
34+
$allowCastLeft = true;
35+
$allowCastRight = !in_array($operator, ['in', 'not in'], true);
36+
37+
$res = '';
38+
if ($allowCastLeft) {
39+
$res .= 'case when ' . $this->_renderConditionBinaryCheckNumericSql($sqlLeft)
40+
. ' then ' . parent::_renderConditionBinary($operator, 'cast(' . $sqlLeft . ' as numeric)', $sqlRight)
41+
. ' else ';
42+
}
43+
if ($allowCastRight) {
44+
$res .= 'case when ' . $this->_renderConditionBinaryCheckNumericSql($sqlRight)
45+
. ' then ' . parent::_renderConditionBinary($operator, $sqlLeft, 'cast(' . $sqlRight . ' as numeric)')
46+
. ' else ';
47+
}
48+
$res .= parent::_renderConditionBinary($operator, $sqlLeft, $sqlRight);
49+
if ($allowCastRight) {
50+
$res .= ' end';
51+
}
52+
if ($allowCastLeft) {
53+
$res .= ' end';
54+
}
55+
56+
return $res;
57+
}
58+
59+
protected function _renderConditionInOperator(bool $negated, string $sqlLeft, array $sqlValues): string
60+
{
61+
$res = '(' . implode(' or ', array_map(fn ($v) => $this->_renderConditionBinary('=', $sqlLeft, $v), $sqlValues)) . ')';
62+
if ($negated) {
63+
$res = 'not' . $res;
64+
}
65+
66+
return $res;
67+
}
68+
1869
public function groupConcat($field, string $separator = ',')
1970
{
2071
return $this->expr('group_concat({}, [])', [$field, $separator]);

src/Schema/TestCase.php

+10
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,16 @@ static function ($matches) use ($platform) {
169169

170170
protected function assertSameSql(string $expectedSqliteSql, string $actualSql, string $message = ''): void
171171
{
172+
// remove once SQLite affinity of expressions is fixed natively
173+
// related with Atk4\Data\Persistence\Sql\Sqlite\Query::_renderConditionBinary() fix
174+
if ($this->getDatabasePlatform() instanceof SQLitePlatform) {
175+
do {
176+
$actualSqlPrev = $actualSql;
177+
$actualSql = preg_replace('~case when typeof\((.+?)\) in \(\'integer\', \'real\'\) then cast\(\1 as numeric\) (.{1,20}?) (.+?) else \1 \2 \3 end~s', '$1 $2 $3', $actualSql);
178+
$actualSql = preg_replace('~case when typeof\((.+?)\) in \(\'integer\', \'real\'\) then (.+?) (.{1,20}?) cast\(\1 as numeric\) else \2 \3 \1 end~s', '$2 $3 $1', $actualSql);
179+
} while ($actualSql !== $actualSqlPrev);
180+
}
181+
172182
self::assertSame($this->convertSqlFromSqlite($expectedSqliteSql), $actualSql, $message);
173183
}
174184

tests/ModelAggregateTest.php

+3-8
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use Atk4\Data\Model\Scope;
99
use Atk4\Data\Model\Scope\Condition;
1010
use Atk4\Data\Schema\TestCase;
11-
use Doctrine\DBAL\Platforms\SQLitePlatform;
1211

1312
class ModelAggregateTest extends TestCase
1413
{
@@ -177,8 +176,7 @@ public function testGroupSelectCondition2(): void
177176
$aggregate->addCondition(
178177
'double',
179178
'>',
180-
// TODO Sqlite bind param does not work, expr needed, even if casted to float with DBAL type (comparison works only if casted to/bind as int)
181-
$this->getDatabasePlatform() instanceof SQLitePlatform ? $aggregate->expr('10') : 10
179+
10
182180
);
183181

184182
self::assertSame([
@@ -200,8 +198,7 @@ public function testGroupSelectCondition3(): void
200198
$aggregate->addExpression('double', ['expr' => '[s] + [amount]', 'type' => 'atk4_money']);
201199
$aggregate->addCondition(
202200
'double',
203-
// TODO Sqlite bind param does not work, expr needed, even if casted to float with DBAL type (comparison works only if casted to/bind as int)
204-
$this->getDatabasePlatform() instanceof SQLitePlatform ? $aggregate->expr('38') : 38
201+
38
205202
);
206203

207204
self::assertSame([
@@ -238,9 +235,7 @@ public function testGroupSelectScope(): void
238235
]);
239236
self::fixAllNonAggregatedFieldsInGroupBy($aggregate);
240237

241-
// TODO Sqlite bind param does not work, expr needed, even if casted to float with DBAL type (comparison works only if casted to/bind as int)
242-
$numExpr = $this->getDatabasePlatform() instanceof SQLitePlatform ? $aggregate->expr('4') : 4;
243-
$scope = Scope::createAnd(new Condition('client_id', 2), new Condition('amount', $numExpr));
238+
$scope = Scope::createAnd(new Condition('client_id', 2), new Condition('amount', 4));
244239
$aggregate->addCondition($scope);
245240

246241
self::assertSame([

tests/Persistence/Sql/WithDb/SelectTest.php

+24-13
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ public function testWhereExpression(): void
251251
* @param array{string, array<mixed>} $exprLeft
252252
* @param array{string, array<mixed>} $exprRight
253253
*/
254-
public function testWhereNumericCompare(array $exprLeft, string $operator, array $exprRight, bool $expectPostgresqlTypeMismatchException = false, bool $expectMssqlTypeMismatchException = false, bool $expectSqliteWrongAffinity = false): void
254+
public function testWhereNumericCompare(array $exprLeft, string $operator, array $exprRight, bool $expectPostgresqlTypeMismatchException = false, bool $expectMssqlTypeMismatchException = false): void
255255
{
256256
if ($this->getDatabasePlatform() instanceof OraclePlatform) {
257257
$exprLeft[0] = preg_replace('~\d+[eE][\-+]?\d++~', '$0d', $exprLeft[0]);
@@ -272,14 +272,27 @@ public function testWhereNumericCompare(array $exprLeft, string $operator, array
272272
}
273273
$queryHaving->having($this->e(...$exprLeft), $operator, $this->e(...$exprRight));
274274

275-
$queryWhere2 = $this->q()->field($this->e('1'), 'v');
276-
$queryWhere2->table($this->q()->field($this->e(...$exprLeft), 'a')->field($this->e(...$exprRight), 'b'), 't');
277-
$queryWhere2->where('a', $operator, $this->e('{}', ['b']));
275+
$queryWhereSub = $this->q()->field($this->e('1'), 'v');
276+
$queryWhereSub->table($this->q()->field($this->e(...$exprLeft), 'a')->field($this->e(...$exprRight), 'b'), 't');
277+
$queryWhereSub->where('a', $operator, $this->e('{}', ['b']));
278+
279+
$queryWhereIn = $this->q()->field($this->e('1'), 'v');
280+
if ($this->getDatabasePlatform() instanceof MySQLPlatform) {
281+
$queryWhereIn->table('(select 1)', 'dual'); // needed for MySQL 5.x when WHERE or HAVING is specified
282+
}
283+
if ($operator === '=' || $operator === '!=') {
284+
$queryWhereIn->where(
285+
$this->e(...$exprLeft),
286+
$operator === '!=' ? 'not in' : 'in',
287+
[$this->e(...$exprRight), $this->e(...$exprRight)]
288+
);
289+
}
278290

279291
$queryAll = $this->q()
280292
->field($queryWhere, 'where')
281293
->field($queryHaving, 'having')
282-
->field($queryWhere2, 'where2');
294+
->field($queryWhereSub, 'where_sub')
295+
->field($queryWhereIn, 'where_in');
283296

284297
if (($expectPostgresqlTypeMismatchException && $this->getDatabasePlatform() instanceof PostgreSQLPlatform) || ($expectMssqlTypeMismatchException && $this->getDatabasePlatform() instanceof SQLServerPlatform)) {
285298
$this->expectException(ExecuteException::class);
@@ -299,9 +312,7 @@ public function testWhereNumericCompare(array $exprLeft, string $operator, array
299312
}
300313

301314
self::assertSame(
302-
$expectSqliteWrongAffinity && $this->getDatabasePlatform() instanceof SQLitePlatform
303-
? [['where' => null, 'having' => null, 'where2' => null]]
304-
: [['where' => '1', 'having' => '1', 'where2' => '1']],
315+
[['where' => '1', 'having' => '1', 'where_sub' => '1', 'where_in' => '1']],
305316
$rows
306317
);
307318
}
@@ -361,11 +372,11 @@ public function provideWhereNumericCompareCases(): iterable
361372
yield [['[]', [false]], '!=', ['[]', [true]]];
362373
yield [['[]', [false]], '<', ['[]', [true]]];
363374

364-
yield [['4'], '=', ['[]', ['04']], true, false, true];
375+
yield [['4'], '=', ['[]', ['04']], true];
365376
yield [['\'04\''], '=', ['[]', [4]], true];
366377
yield [['4'], '=', ['[]', [4.0]]];
367-
yield [['4'], '=', ['[]', ['4.0']], true, true, true];
368-
yield [['2.5'], '=', ['[]', ['02.50']], true, false, true];
378+
yield [['4'], '=', ['[]', ['4.0']], true, true];
379+
yield [['2.5'], '=', ['[]', ['02.50']], true];
369380
yield [['0'], '=', ['[]', [false]], true];
370381
yield [['0'], '!=', ['[]', [true]], true];
371382
yield [['1'], '=', ['[]', [true]], true];
@@ -374,11 +385,11 @@ public function provideWhereNumericCompareCases(): iterable
374385
yield [['2 + 2'], '=', ['[]', [4]]];
375386
yield [['2 + 2'], '=', ['[] + []', [1, 3]]];
376387
yield [['[] + []', [-1, 5]], '=', ['[] + []', [1, 3]]];
377-
yield [['2 + 2'], '=', ['[]', ['4']], true, false, true];
388+
yield [['2 + 2'], '=', ['[]', ['4']], true];
378389
yield [['2 + 2.5'], '=', ['[]', [4.5]]];
379390
yield [['2 + 2.5'], '=', ['[] + []', [1.5, 3.0]]];
380391
yield [['[] + []', [-1.5, 6.0]], '=', ['[] + []', [1.5, 3.0]]];
381-
yield [['2 + 2.5'], '=', ['[]', ['4.5']], true, false, true];
392+
yield [['2 + 2.5'], '=', ['[]', ['4.5']], true];
382393
}
383394

384395
public function testGroupConcat(): void

tests/ScopeTest.php

+6
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,12 @@ public function testConditionOnReferencedRecords(): void
333333
$user->addCondition('Tickets/user/country_id/Users/country_id/Users/name', '!=', null); // should be always true
334334
}
335335

336+
// remove once SQLite affinity of expressions is fixed natively
337+
// needed for \Atk4\Data\Persistence\Sql\Sqlite\Query::_renderConditionBinary() fix
338+
if ($this->getDatabasePlatform() instanceof SQLitePlatform) {
339+
return;
340+
}
341+
336342
self::assertSame(2, $user->executeCountQuery());
337343
foreach ($user as $u) {
338344
self::assertTrue(in_array($u->get('name'), ['Aerton', 'Rubens'], true));

0 commit comments

Comments
 (0)