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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
vendor/
composer.lock
.php-cs-fixer.cache
/build/
/build/*
!/build/.gitkeep
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## Unreleased
## 1.3.3 — 2026-09-13

### Fixed

Expand All @@ -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

Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Empty file added build/.gitkeep
Empty file.
11 changes: 8 additions & 3 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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='=')
Expand All @@ -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
```

Expand All @@ -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

Expand Down
17 changes: 16 additions & 1 deletion src/QueryBuildingVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int<0, max>|non-empty-string, mixed> $subQueryParams */
$subQueryParams = $subQuery->getParams();
[$where, $params] = $this->remapConditionAndParams($where, $subQueryParams);
Expand Down Expand Up @@ -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);
}
Expand Down
13 changes: 13 additions & 0 deletions tests/Integration/SqliteIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 29 additions & 4 deletions tests/QueryBuildingVisitorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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<array-key, mixed> $condition
* @param array<array-key, mixed> $expected
Expand Down Expand Up @@ -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();
Expand Down
Loading