Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased

### Fixed

- An OR condition written as `['is', value]` / `['is not', value]` (or handed
to `OrConditionSpecification` as `['is', column, value]`) was passed to
yiisoft/db verbatim and rendered `col IS 'value'` — a syntax error on MySQL
and PostgreSQL that SQLite alone accepts. The OR path now maps `is` / `is not`
to `=` / `!=` exactly as `visitComparison()` does: a `null` operand still
renders `IS NULL` / `IS NOT NULL`, any other operand a plain equality (#34).

## 1.3.0 — 2026-09-13

### Fixed
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,12 @@ Benchmarks live in `benchmarks/` and run via `composer bench` (requires
(`'age' => ['>', 18]`, `'type' => ['in', ['a', 'b']]`); any other array is treated as
a value, so a plain list (`'name' => ['a', 'b']`) becomes an `IN` condition. The
operator is matched case-insensitively. A `['like', pattern]` entry (and the
other three LIKE operators) sends the pattern verbatim; a condition handed to
`OrConditionSpecification` directly is normalized the same way only in its
three-element `[operator, column, pattern]` form.
other three LIKE operators) sends the pattern verbatim. `['is', value]` and
`['is not', value]` are rendered as `=` / `!=`, exactly like the comparison
path: a `null` operand becomes `IS NULL` / `IS NOT NULL`, anything else a plain
equality — never a verbatim `IS 'value'`, which MySQL and PostgreSQL reject.
A condition handed to `OrConditionSpecification` directly is normalized the
same way only in its three-element `[operator, column, value]` form.

## License

Expand Down
9 changes: 6 additions & 3 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,12 @@ $spec = CompositeSpecification::create()
другой массив трактуется как значение, поэтому простой список
(`'name' => ['a', 'b']`) становится условием `IN`. Оператор сопоставляется
case-insensitively. Запись `['like', pattern]` (и остальные три LIKE-оператора)
отправляет паттерн как есть; условие, переданное в `OrConditionSpecification`
напрямую, нормализуется так же только в трёхэлементной форме
`[operator, column, pattern]`.
отправляет паттерн как есть. `['is', value]` и `['is not', value]`
рендерятся как `=` / `!=` — так же, как на пути сравнения: операнд `null`
становится `IS NULL` / `IS NOT NULL`, любой другой — обычным равенством,
и никогда — буквальным `IS 'value'`, которое MySQL и PostgreSQL отвергают.
Условие, переданное в `OrConditionSpecification` напрямую, нормализуется так
же только в трёхэлементной форме `[operator, column, value]`.

## Лицензия

Expand Down
12 changes: 9 additions & 3 deletions src/QueryBuildingVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@
#[\Override]
public function visitComparison(ComparisonSpecification $specification): void
{
$operator = mb_strtolower(string: $specification->getOperator());

Check warning on line 26 in src/QueryBuildingVisitor.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "MBString": @@ @@ #[\Override] public function visitComparison(ComparisonSpecification $specification): void { - $operator = mb_strtolower(string: $specification->getOperator()); + $operator = strtolower(string: $specification->getOperator()); switch ($operator) { case 'between':

switch ($operator) {

Check warning on line 28 in src/QueryBuildingVisitor.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "SharedCaseRemoval": @@ @@ } $this->query->andWhere([$operator, $specification->getColumn(), $this->normalizeValue(value: $value[0]), $this->normalizeValue(value: $value[1])]); break; - case 'in': - case 'not in': $value = $specification->getValue(); if (!is_array(value: $value)) { throw new \InvalidArgumentException( @@ @@ } $this->query->andWhere([$operator, $specification->getColumn(), array_map(callback: $this->normalizeValue(...), array: $value)]); break; - case 'is': case 'is not': $this->query->andWhere([ @@ @@ $specification->getValue(), ]); break; - case 'like': case 'not like': case 'ilike': @@ @@ $value = $specification->getValue(); $this->query->andWhere($this->likeCondition($operator, $specification->getColumn(), $value, $specification->getLikeMatch())); break; - default: $this->query->andWhere([$operator, $specification->getColumn(), $this->normalizeValue(value: $specification->getValue())]); }
case 'between':
case 'not between':
$value = $specification->getValue();
if (!is_array(value: $value) || count(value: $value) !== 2) {

Check warning on line 32 in src/QueryBuildingVisitor.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ case 'between': case 'not between': $value = $specification->getValue(); - if (!is_array(value: $value) || count(value: $value) !== 2) { + if (!is_array(value: $value) && count(value: $value) !== 2) { throw new \InvalidArgumentException( message: sprintf('%s operator requires array with exactly two values', strtoupper(string: $operator)), );
throw new \InvalidArgumentException(
message: sprintf('%s operator requires array with exactly two values', strtoupper(string: $operator)),
);
Expand Down Expand Up @@ -162,7 +162,10 @@

/**
* A LIKE written as `[operator, column, pattern]` carries the caller's
* wildcards in the pattern, so it is sent verbatim.
* wildcards in the pattern, so it is sent verbatim. `is` / `is not` are
* mapped to `=` / `!=` exactly as visitComparison() does: yiisoft/db
* renders `IS NULL` for a null operand, while a verbatim `IS 'value'` is a
* syntax error on MySQL and PostgreSQL (#34).
*
* @param array<array-key, mixed> $condition
* @return array<array-key, mixed>
Expand All @@ -173,11 +176,14 @@
return $condition;
}
[$operator, $column, $value] = $condition;
if (!is_string(value: $operator) || !is_string(value: $column) || !is_string(value: $value)) {
if (!is_string(value: $operator) || !is_string(value: $column)) {
return $condition;
}
$operator = mb_strtolower(string: $operator);

Check warning on line 182 in src/QueryBuildingVisitor.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "MBString": @@ @@ if (!is_string(value: $operator) || !is_string(value: $column)) { return $condition; } - $operator = mb_strtolower(string: $operator); + $operator = strtolower(string: $operator); if ($operator === 'is' || $operator === 'is not') { return [$operator === 'is' ? '=' : '!=', $column, $value]; }
if (!in_array(needle: $operator, haystack: self::LIKE_OPERATORS, strict: true)) {
if ($operator === 'is' || $operator === 'is not') {
return [$operator === 'is' ? '=' : '!=', $column, $value];
}
if (!is_string(value: $value) || !in_array(needle: $operator, haystack: self::LIKE_OPERATORS, strict: true)) {
return $condition;
}

Expand Down Expand Up @@ -336,7 +342,7 @@
}

$keys = array_keys($condition);
$values = array_values($condition);

Check warning on line 345 in src/QueryBuildingVisitor.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "UnwrapArrayValues": @@ @@ } $keys = array_keys($condition); - $values = array_values($condition); + $values = $condition; $normalizedCondition = []; foreach ($keys as $i => $key) {
$normalizedCondition = [];

foreach ($keys as $i => $key) {
Expand Down
19 changes: 19 additions & 0 deletions tests/Integration/SqliteIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,25 @@ public function likeInsideNotAndOr(): void
Assert::same($this->ids($or), [5, 6, 7]);
}

/**
* SQLite reads `IS 'pending'` as null-safe equality, so a verbatim `is`
* passes here while MySQL and PostgreSQL reject it (#34). The SQL is
* asserted, not only the rows: the rows would be the same either way.
*/
public function orConditionIsRendersAsEquality(): void
{
$spec = CompositeSpecification::create()
->withOrCondition(conditions: ['status' => ['is', 'pending'], 'name' => ['is not', null]]);

$query = (new Query($this->db))->from('items');
QueryApplier::apply(specification: $spec, query: $query);
$sql = $query->createCommand()->getRawSql();

Assert::string($sql)->contains('"status" = \'pending\'')->contains('"name" IS NOT NULL');
Assert::string($sql)->notContains(' IS \'');
Assert::same($this->ids($spec), [1, 2, 3, 4, 5]);
}

public function orConditionLikeSendsThePatternVerbatim(): void
{
$this->db->createCommand()->insert('items', ['id' => 6, 'name' => 'Alpha%Beta', 'status' => 'active', 'price' => 60, 'created_at' => '2024-06-01'])->execute();
Expand Down
28 changes: 28 additions & 0 deletions tests/QueryBuildingVisitorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,34 @@ public static function orConditionLikeProvider(): iterable
];
}

/**
* @param array<array-key, mixed> $condition
* @param array<array-key, mixed> $expected
*/
#[DataProvider('orConditionIsProvider')]
public function visitOrConditionMapsIsToEqualityLikeVisitComparison(array $condition, array $expected): void
{
$query = $this->makeQuery();
$visitor = new QueryBuildingVisitor(query: $query);
$spec = new OrConditionSpecification(conditions: [['status' => 'active'], $condition]);

$visitor->visitOrCondition(specification: $spec);

Assert::same($query->getWhere(), ['or', ['status' => 'active'], $expected]);
}

public static function orConditionIsProvider(): iterable
{
yield 'is null' => [['is', 'deleted_at', null], ['=', 'deleted_at', null]];
yield 'is not null' => [['is not', 'deleted_at', null], ['!=', 'deleted_at', null]];
yield 'IS in upper case' => [['IS', 'deleted_at', null], ['=', 'deleted_at', null]];
yield 'is with a string operand' => [['is', 'status', 'pending'], ['=', 'status', 'pending']];
yield 'is not with a string operand' => [['is not', 'status', 'pending'], ['!=', 'status', 'pending']];
yield 'is with a bool operand' => [['is', 'flag', true], ['=', 'flag', true]];
yield 'an is with a non-string column is untouched' => [['is', ['deleted_at'], null], ['is', ['deleted_at'], null]];
yield 'an is with two operands is untouched' => [['is', 'deleted_at'], ['is', 'deleted_at']];
}

public function visitOrConditionEmpty(): void
{
$query = $this->makeQuery();
Expand Down
Loading