diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d0cd71..d0350a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5650928..662e997 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.ru.md b/README.ru.md index e92b4c8..b6f2808 100644 --- a/README.ru.md +++ b/README.ru.md @@ -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]`. ## Лицензия diff --git a/src/QueryBuildingVisitor.php b/src/QueryBuildingVisitor.php index aaf5173..27680cd 100644 --- a/src/QueryBuildingVisitor.php +++ b/src/QueryBuildingVisitor.php @@ -162,7 +162,10 @@ public function visitOrCondition(OrConditionSpecification $specification): void /** * 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 $condition * @return array @@ -173,11 +176,14 @@ private function normalizeOrCondition(array $condition): array 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); - 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; } diff --git a/tests/Integration/SqliteIntegrationTest.php b/tests/Integration/SqliteIntegrationTest.php index c25a575..bf7e4fd 100644 --- a/tests/Integration/SqliteIntegrationTest.php +++ b/tests/Integration/SqliteIntegrationTest.php @@ -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(); diff --git a/tests/QueryBuildingVisitorTest.php b/tests/QueryBuildingVisitorTest.php index 409ceae..02dc462 100644 --- a/tests/QueryBuildingVisitorTest.php +++ b/tests/QueryBuildingVisitorTest.php @@ -189,6 +189,34 @@ public static function orConditionLikeProvider(): iterable ]; } + /** + * @param array $condition + * @param array $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();