diff --git a/.gitignore b/.gitignore index ac67dbf..48bebb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ vendor/ composer.lock .php-cs-fixer.cache -/build/ +/build/* +!/build/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index 45cc2b0..1798099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.3.3 — 2026-09-13 ### Fixed @@ -15,6 +15,14 @@ `DateTimeValue` with six-digit fractional precision. - Update the test adapter dependency to `rasuvaeff/property-testing-testo` `^0.10`. +- Direct `NOT` specifications now preserve nested `ORDER BY`, `LIMIT` and + `OFFSET` modifiers, matching the existing `OR` behavior. +- Canonical three-element `LIKE` conditions in `OrConditionSpecification` now + reject non-string patterns instead of passing malformed input to the query + builder. +- Track `build/.gitkeep` so coverage and other build outputs have a directory + in fresh checkouts. +- Document the public `OrderBySpecification` and `LimitSpecification` classes. ## 1.3.1 — 2026-09-13 diff --git a/README.md b/README.md index 2e59f03..965b51b 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,12 @@ Building blocks for composing complex conditions: ```php use Rasuvaeff\Specification\ComparisonSpecification; use Rasuvaeff\Specification\CompositeSpecification; +use Rasuvaeff\Specification\LimitSpecification; use Rasuvaeff\Specification\NotSpecification; use Rasuvaeff\Specification\OffsetSpecification; use Rasuvaeff\Specification\OrConditionSpecification; use Rasuvaeff\Specification\OrSpecification; +use Rasuvaeff\Specification\OrderBySpecification; use Rasuvaeff\Specification\RawSpecification; // AND conditions @@ -146,6 +148,11 @@ $rawComposite = CompositeSpecification::create() ->withRaw('price > :min AND price < :max', ['min' => 10, 'max' => 100]); ``` +`OrderBySpecification`, `LimitSpecification` and `OffsetSpecification` are the +standalone query-modifier specifications used by `withOrderBy()`, `withLimit()` +and `withOffset()`. Modifiers written inside an `OR` or `NOT` tree still apply +to the complete query. + ### ComparisonSpecification factory methods ```php @@ -295,12 +302,13 @@ Benchmarks live in `benchmarks/` and run via `composer bench` (requires 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. Its - list-form operator must be one of the allow-listed comparison operators. + list-form operator must be one of the allow-listed comparison operators, and + canonical `LIKE` forms require a string pattern. - `orWhere()` and `notWhere()` callbacks may return the nested builder; doing so is required when the callback itself contains another `orWhere()` or `notWhere()`. `ORDER BY`, `LIMIT` and `OFFSET` are query-level modifiers and - apply to the complete OR expression, even when written before or inside the - callback. + apply to the complete OR/NOT expression, even when written before or inside + the callback (or nested directly in the specification tree). - `DateTimeInterface` values are rendered through `yiisoft/db`'s timezone-aware `DateTimeValue` with six fractional digits. The target column should support the resulting timezone-aware datetime representation. diff --git a/README.ru.md b/README.ru.md index cd412a2..ce24539 100644 --- a/README.ru.md +++ b/README.ru.md @@ -104,10 +104,12 @@ $rows = $query->all(); ```php use Rasuvaeff\Specification\ComparisonSpecification; use Rasuvaeff\Specification\CompositeSpecification; +use Rasuvaeff\Specification\LimitSpecification; use Rasuvaeff\Specification\NotSpecification; use Rasuvaeff\Specification\OffsetSpecification; use Rasuvaeff\Specification\OrConditionSpecification; use Rasuvaeff\Specification\OrSpecification; +use Rasuvaeff\Specification\OrderBySpecification; use Rasuvaeff\Specification\RawSpecification; // AND conditions @@ -146,6 +148,11 @@ $rawComposite = CompositeSpecification::create() ->withRaw('price > :min AND price < :max', ['min' => 10, 'max' => 100]); ``` +`OrderBySpecification`, `LimitSpecification` и `OffsetSpecification` — +самостоятельные спецификации модификаторов запроса, которые используются +методами `withOrderBy()`, `withLimit()` и `withOffset()`. Модификаторы, +записанные внутри дерева `OR` или `NOT`, применяются ко всему запросу. + ### Фабричные методы ComparisonSpecification ```php @@ -301,11 +308,13 @@ $spec = CompositeSpecification::create() и никогда — буквальным `IS 'value'`, которое MySQL и PostgreSQL отвергают. Условие, переданное в `OrConditionSpecification` напрямую, нормализуется так же только в трёхэлементной форме `[operator, column, value]`. Оператор в - списковой форме обязан быть в allow-list операторов сравнения. + списковой форме обязан быть в allow-list операторов сравнения, а каноническая + форма `LIKE` должна содержать строковый паттерн. - Колбэки `orWhere()` и `notWhere()` могут возвращать вложенный билдер; это обязательно, если внутри колбэка есть ещё один `orWhere()` или `notWhere()`. `ORDER BY`, `LIMIT` и `OFFSET` — модификаторы всего запроса: они применяются - ко всему OR-выражению, даже если записаны до колбэка или внутри него. + ко всему OR/NOT-выражению, даже если записаны до колбэка, внутри него или + непосредственно во вложенном дереве спецификаций. - Значения `DateTimeInterface` проходят через timezone-aware `DateTimeValue` из `yiisoft/db` с шестью знаками дробной секунды. Целевая колонка должна поддерживать получаемое представление даты со timezone. diff --git a/build/.gitkeep b/build/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/llms.txt b/llms.txt index c375fd6..9e0db15 100644 --- a/llms.txt +++ b/llms.txt @@ -84,7 +84,8 @@ SpecificationBuilder::create() ```php use Rasuvaeff\Specification\{CompositeSpecification, ComparisonSpecification, - OrSpecification, NotSpecification, RawSpecification}; + OrSpecification, NotSpecification, RawSpecification, OrderBySpecification, + LimitSpecification}; CompositeSpecification::create() // AND container ->withComparison('status','active') // (col, value, operator='=') @@ -100,6 +101,8 @@ CompositeSpecification::create() // AND container OrSpecification::create($specA, $specB, ...); // OR of specs (isolated params) new NotSpecification($spec); new RawSpecification('price > :min', ['min' => 30]); // value via params only +new OrderBySpecification(['created_at' => 'DESC']); // standalone ORDER BY +new LimitSpecification(20); // standalone LIMIT new OffsetSpecification(40); // standalone offset ``` @@ -113,8 +116,10 @@ Valid operators: `= != <> > >= < <= like not like ilike not ilike in not in between not between is is not`. Validation: NULL only with `= != <> is is not`; `in/not in/between/not between` require array (`between` is a zero-based list of exactly two values); LIKE ops require string. Direct list-form conditions in -`OrConditionSpecification` reject unknown operators. `DateTimeInterface` values -use yiisoft/db `DateTimeValue` with timezone and six fractional digits. +`OrConditionSpecification` reject unknown operators and non-string canonical LIKE +patterns. Modifiers in OR/NOT trees apply to the complete query. +`DateTimeInterface` values use yiisoft/db `DateTimeValue` with timezone and six +fractional digits. ## Performance diff --git a/src/QueryBuildingVisitor.php b/src/QueryBuildingVisitor.php index 34ac4f9..5b890bc 100644 --- a/src/QueryBuildingVisitor.php +++ b/src/QueryBuildingVisitor.php @@ -116,6 +116,16 @@ public function visitNot(NotSpecification $specification): void throw new \InvalidArgumentException('NOT specification cannot be empty'); } + if ($subQuery->getOrderBy() !== []) { + $this->query->addOrderBy(columns: $subQuery->getOrderBy()); + } + if (($limit = $subQuery->getLimit()) !== null) { + $this->query->limit(limit: $limit); + } + if (($offset = $subQuery->getOffset()) !== null) { + $this->query->offset(offset: $offset); + } + /** @var array|non-empty-string, mixed> $subQueryParams */ $subQueryParams = $subQuery->getParams(); [$where, $params] = $this->remapConditionAndParams($where, $subQueryParams); @@ -201,9 +211,14 @@ private function normalizeOrCondition(array $condition): array if ($operator === 'is' || $operator === 'is not') { return [$operator === 'is' ? '=' : '!=', $column, $this->normalizeValue(value: $value)]; } - if (!is_string(value: $value) || !in_array(needle: $operator, haystack: self::LIKE_OPERATORS, strict: true)) { + if (!in_array(needle: $operator, haystack: self::LIKE_OPERATORS, strict: true)) { return $this->normalizeCondition(condition: $condition); } + if (!is_string(value: $value)) { + throw new \InvalidArgumentException( + message: sprintf('Operator "%s" requires string value', $operator), + ); + } return $this->likeCondition($operator, $column, $value, LikeMatch::Pattern); } diff --git a/tests/Integration/SqliteIntegrationTest.php b/tests/Integration/SqliteIntegrationTest.php index 8db597d..9ed7f20 100644 --- a/tests/Integration/SqliteIntegrationTest.php +++ b/tests/Integration/SqliteIntegrationTest.php @@ -101,6 +101,19 @@ public function notNegatesCondition(): void Assert::same($this->ids($spec), [3, 5]); } + public function directNotSpecificationPreservesPagination(): void + { + $spec = CompositeSpecification::create()->withNot( + specification: CompositeSpecification::create() + ->withComparison(column: 'status', value: 'active') + ->withOrderBy(columns: ['price' => 'DESC']) + ->withLimit(limit: 1) + ->withOffset(offset: 1), + ); + + Assert::same($this->ids($spec), [3]); + } + public function orBranchesIsolateParameters(): void { $spec = CompositeSpecification::create()->withSpecification( diff --git a/tests/QueryBuildingVisitorTest.php b/tests/QueryBuildingVisitorTest.php index bfcb858..748d661 100644 --- a/tests/QueryBuildingVisitorTest.php +++ b/tests/QueryBuildingVisitorTest.php @@ -191,10 +191,6 @@ public static function orConditionLikeProvider(): iterable ['>', 'age', 18], ['>', 'age', 18], ]; - yield 'a like with a non-string pattern is untouched' => [ - ['like', 'name', 5], - ['like', 'name', 5], - ]; yield 'a like with a non-string column is untouched' => [ ['like', ['name'], 'x'], ['like', ['name'], 'x'], @@ -205,6 +201,17 @@ public static function orConditionLikeProvider(): iterable ]; } + public function visitOrConditionRejectsNonStringLikePattern(): void + { + Expect::exception(InvalidArgumentException::class)->withMessageContaining('Operator "like" requires string value'); + + $query = $this->makeQuery(); + $visitor = new QueryBuildingVisitor(query: $query); + $spec = new OrConditionSpecification(conditions: [['like', 'name', 5]]); + + $visitor->visitOrCondition(specification: $spec); + } + /** * @param array $condition * @param array $expected @@ -568,6 +575,24 @@ public function visitNotWithComparison(): void Assert::same($where[0], 'not'); } + public function visitNotHoistsQueryModifiersFromChild(): void + { + $query = $this->makeQuery(); + $visitor = new QueryBuildingVisitor(query: $query); + $innerSpec = CompositeSpecification::create() + ->withComparison(column: 'status', value: 'active') + ->withOrderBy(columns: ['created_at' => 'DESC']) + ->withLimit(limit: 2) + ->withOffset(offset: 1); + + $visitor->visitNot(specification: new NotSpecification(specification: $innerSpec)); + + Assert::same($query->getWhere(), ['not', ['=', 'status', 'active']]); + Assert::same($query->getOrderBy(), ['created_at' => SORT_DESC]); + Assert::same($query->getLimit(), 2); + Assert::same($query->getOffset(), 1); + } + public function visitNotWithDoubleNotUnwraps(): void { $query = $this->makeQuery();