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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,13 @@ Integration tests need no external DB — they use in-memory SQLite (the
`SqliteIntegrationTest` guards — extend it for any change to OR/NOT/subquery
handling, and run it.
- `CompositeSpecification` composes with **AND**. For OR use `OrSpecification` or
`SpecificationBuilder::orWhere()`.
`SpecificationBuilder::orWhere()`. A callback that contains nested
`orWhere()`/`notWhere()` must return the nested builder. Query modifiers remain
outside OR branches.
- `ComparisonSpecification` validates operators (whitelist) and value↔operator
consistency (NULL only with `=,!=,<>,is,is not`; array ops require arrays;
`between` requires exactly two; LIKE ops require strings).
`between` requires a zero-based list of exactly two values; LIKE ops require
strings).
- `ilike`/`not ilike` are `LIKE` with `caseSensitive: false` (`ILIKE` on
PostgreSQL, plain `LIKE` elsewhere). `startsWith`/`endsWith`/`contains` carry
a `LikeMatch` and hand a clean string to the builder, which escapes and adds
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## Unreleased

### Fixed

- Reject unknown operators in direct `OrConditionSpecification` conditions
instead of allowing SQL syntax to be injected through the operator position.
- `SpecificationBuilder::orWhere()` now keeps `ORDER BY`, `LIMIT` and `OFFSET`
outside OR branches, and nested `orWhere()` / `notWhere()` callbacks retain
the builder returned by the callback.
- `BETWEEN` values must be zero-based lists with exactly two elements, so
malformed associative arrays fail during specification construction.
- `DateTimeInterface` values are passed through `yiisoft/db`'s timezone-aware
`DateTimeValue` with six-digit fractional precision.
- Update the test adapter dependency to `rasuvaeff/property-testing-testo`
`^0.10`.

## 1.3.1 — 2026-09-13

### Fixed
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ composer install && php examples/builder.php

- **Values are parameterized.** All comparison/IN/BETWEEN/LIKE values are bound
as parameters by `yiisoft/db`, so they are safe against SQL injection.
- **OR condition operators are allow-listed.** Direct
`OrConditionSpecification` conditions accept only the same canonical
operators as `ComparisonSpecification`; an unknown operator throws
`InvalidArgumentException` before query generation.
- **Column names are not validated** — they are passed to `yiisoft/db` and quoted
as identifiers, but there is no allow-list. Pass only **trusted** column names
(typically hard-coded), never raw user input.
Expand Down Expand Up @@ -290,7 +294,16 @@ Benchmarks live in `benchmarks/` and run via `composer bench` (requires
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.
same way only in its three-element `[operator, column, value]` form. Its
list-form operator must be one of the allow-listed comparison operators.
- `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.
- `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.

## License

Expand Down
14 changes: 13 additions & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ composer install && php examples/builder.php

- **Значения параметризованы.** Все значения в сравнениях/IN/BETWEEN/LIKE
биндятся `yiisoft/db` как параметры, поэтому защищены от SQL-инъекций.
- **Операторы OR проверяются по allow-list.** В прямых условиях
`OrConditionSpecification` разрешены только те же канонические операторы,
что и у `ComparisonSpecification`; неизвестный оператор вызывает
`InvalidArgumentException` до построения запроса.
- **Имена колонок не валидируются** — они передаются в `yiisoft/db` и
квотируются как идентификаторы, но allow-list'а нет. Передавайте только
**доверенные** имена колонок (как правило, захардкоженные), никогда — сырой
Expand Down Expand Up @@ -296,7 +300,15 @@ $spec = CompositeSpecification::create()
становится `IS NULL` / `IS NOT NULL`, любой другой — обычным равенством,
и никогда — буквальным `IS 'value'`, которое MySQL и PostgreSQL отвергают.
Условие, переданное в `OrConditionSpecification` напрямую, нормализуется так
же только в трёхэлементной форме `[operator, column, value]`.
же только в трёхэлементной форме `[operator, column, value]`. Оператор в
списковой форме обязан быть в allow-list операторов сравнения.
- Колбэки `orWhere()` и `notWhere()` могут возвращать вложенный билдер; это
обязательно, если внутри колбэка есть ещё один `orWhere()` или `notWhere()`.
`ORDER BY`, `LIMIT` и `OFFSET` — модификаторы всего запроса: они применяются
ко всему OR-выражению, даже если записаны до колбэка или внутри него.
- Значения `DateTimeInterface` проходят через timezone-aware
`DateTimeValue` из `yiisoft/db` с шестью знаками дробной секунды. Целевая
колонка должна поддерживать получаемое представление даты со timezone.

## Лицензия

Expand Down
20 changes: 10 additions & 10 deletions benchmarks/OrCompositionBench.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,11 @@ final class OrCompositionBench
{
#[Bench(
callables: [
'direct' => [self::class, 'buildDirect'],
'builder' => [self::class, 'buildViaOrWhere'],
],
calls: 1_000,
iterations: 10,
)]
public static function buildViaOrWhere(): CompositeSpecification
{
return SpecificationBuilder::create()
->whereEqual('status', 'active')
->orWhere(fn($b) => $b->whereEqual('status', 'pending'))
->orWhere(fn($b) => $b->whereEqual('status', 'trial'))
->build();
}

public static function buildDirect(): CompositeSpecification
{
return CompositeSpecification::create()
Expand All @@ -47,4 +38,13 @@ public static function buildDirect(): CompositeSpecification
->withComparison(column: 'status', value: 'trial', operator: '='),
));
}

public static function buildViaOrWhere(): CompositeSpecification
{
return SpecificationBuilder::create()
->whereEqual('status', 'active')
->orWhere(static fn(SpecificationBuilder $builder): SpecificationBuilder => $builder->whereEqual('status', 'pending'))
->orWhere(static fn(SpecificationBuilder $builder): SpecificationBuilder => $builder->whereEqual('status', 'trial'))
->build();
}
}
26 changes: 13 additions & 13 deletions benchmarks/SpecificationBuilderBench.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@ final class SpecificationBuilderBench
{
#[Bench(
callables: [
'direct' => [self::class, 'buildDirect'],
'builder' => [self::class, 'buildViaBuilder'],
],
calls: 1_000,
iterations: 10,
)]
public static function buildDirect(): CompositeSpecification
{
return CompositeSpecification::create()
->withComparison(column: 'status', value: 'active', operator: '=')
->withComparison(column: 'age', value: 18, operator: '>')
->withComparison(column: 'role', value: ['admin', 'editor', 'viewer'], operator: 'in')
->withComparison(column: 'email', value: '%@example.com', operator: 'like')
->withComparison(column: 'verified_at', value: null, operator: 'is not')
->withLimit(limit: 100)
->withOffset(offset: 0);
}

public static function buildViaBuilder(): CompositeSpecification
{
return SpecificationBuilder::create()
Expand All @@ -33,16 +45,4 @@ public static function buildViaBuilder(): CompositeSpecification
->offset(0)
->build();
}

public static function buildDirect(): CompositeSpecification
{
return CompositeSpecification::create()
->withComparison(column: 'status', value: 'active', operator: '=')
->withComparison(column: 'age', value: 18, operator: '>')
->withComparison(column: 'role', value: ['admin', 'editor', 'viewer'], operator: 'in')
->withComparison(column: 'email', value: '%@example.com', operator: 'like')
->withComparison(column: 'verified_at', value: null, operator: 'is not')
->withLimit(limit: 100)
->withOffset(offset: 0);
}
}
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"friendsofphp/php-cs-fixer": "^3.95",
"infection/infection": "^0.33",
"maglnet/composer-require-checker": "^4.17",
"rasuvaeff/property-testing-testo": "^0.4",
"rasuvaeff/property-testing-testo": "^0.10",
"rasuvaeff/rector-named-literals": "^1.0",
"rector/rector": "^2.4",
"roave/backward-compatibility-check": "^8.0",
Expand Down
6 changes: 4 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,10 @@ in, notIn, between(col,from,to), notBetween(col,from,to), isNull, isNotNull
```
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` exactly two); LIKE ops
require string. `DateTimeInterface` values are normalized to `Y-m-d H:i:s`.
`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.

## Performance

Expand Down
5 changes: 4 additions & 1 deletion src/ComparisonSpecification.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
string $operator = '=',
?LikeMatch $likeMatch = null,
) {
$this->operator = mb_strtolower(string: $operator);

Check warning on line 33 in src/ComparisonSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "MBString": @@ @@ string $operator = '=', ?LikeMatch $likeMatch = null, ) { - $this->operator = mb_strtolower(string: $operator); + $this->operator = strtolower(string: $operator); $this->likeMatch = $likeMatch ?? LikeMatch::Pattern; $this->validateOperator(); $this->validateValue();
$this->likeMatch = $likeMatch ?? LikeMatch::Pattern;
$this->validateOperator();
$this->validateValue();
Expand Down Expand Up @@ -70,7 +70,7 @@
);
}

return;

Check warning on line 73 in src/ComparisonSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "ReturnRemoval": @@ @@ ); } - return; + } $arrayOperators = ['in', 'not in', 'between', 'not between'];
}

$arrayOperators = ['in', 'not in', 'between', 'not between'];
Expand All @@ -81,7 +81,10 @@
);
}

if (in_array(needle: $this->operator, haystack: ['between', 'not between'], strict: true) && count(value: $this->value) !== 2) {
if (
in_array(needle: $this->operator, haystack: ['between', 'not between'], strict: true)
&& (!array_is_list(array: $this->value) || count(value: $this->value) !== 2)
) {
throw new InvalidArgumentException(
message: sprintf('Operator "%s" requires array with exactly two values', $this->operator),
);
Expand Down
18 changes: 17 additions & 1 deletion src/OrConditionSpecification.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Rasuvaeff\Specification;

use InvalidArgumentException;

/**
* @implements Specification<mixed>
* @api
Expand All @@ -20,7 +22,21 @@
/**
* @param list<array<array-key, mixed>> $conditions
*/
public function __construct(private array $conditions = []) {}
public function __construct(private array $conditions = [])
{
foreach ($conditions as $condition) {
if (!array_is_list(array: $condition) || $condition === [] || !is_string(value: $condition[0])) {

Check warning on line 28 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ public function __construct(private array $conditions = []) { foreach ($conditions as $condition) { - if (!array_is_list(array: $condition) || $condition === [] || !is_string(value: $condition[0])) { + if (!array_is_list(array: $condition) || $condition === [] && !is_string(value: $condition[0])) { continue; }

Check warning on line 28 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "IncrementInteger": @@ @@ public function __construct(private array $conditions = []) { foreach ($conditions as $condition) { - if (!array_is_list(array: $condition) || $condition === [] || !is_string(value: $condition[0])) { + if (!array_is_list(array: $condition) || $condition === [] || !is_string(value: $condition[1])) { continue; }

Check warning on line 28 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ public function __construct(private array $conditions = []) { foreach ($conditions as $condition) { - if (!array_is_list(array: $condition) || $condition === [] || !is_string(value: $condition[0])) { + if (!array_is_list(array: $condition) && $condition === [] || !is_string(value: $condition[0])) { continue; }
continue;

Check warning on line 29 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "Continue_": @@ @@ { foreach ($conditions as $condition) { if (!array_is_list(array: $condition) || $condition === [] || !is_string(value: $condition[0])) { - continue; + break; } $operator = mb_strtolower(string: $condition[0]);
}

$operator = mb_strtolower(string: $condition[0]);

Check warning on line 32 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "MBString": @@ @@ continue; } - $operator = mb_strtolower(string: $condition[0]); + $operator = strtolower(string: $condition[0]); if (!in_array(needle: $operator, haystack: self::VALID_OPERATORS, strict: true)) { throw new InvalidArgumentException( message: sprintf('Invalid OR condition operator "%s"', $condition[0]),
if (!in_array(needle: $operator, haystack: self::VALID_OPERATORS, strict: true)) {
throw new InvalidArgumentException(
message: sprintf('Invalid OR condition operator "%s"', $condition[0]),

Check warning on line 35 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "IncrementInteger": @@ @@ $operator = mb_strtolower(string: $condition[0]); if (!in_array(needle: $operator, haystack: self::VALID_OPERATORS, strict: true)) { throw new InvalidArgumentException( - message: sprintf('Invalid OR condition operator "%s"', $condition[0]), + message: sprintf('Invalid OR condition operator "%s"', $condition[1]), ); } }
);
}
}
}

/**
* @template TVisitorReturn
Expand All @@ -41,11 +57,11 @@
$orConditions = [];

foreach ($conditions as $column => $value) {
$operator = is_array(value: $value) && array_key_exists(key: 0, array: $value) && is_string(value: $value[0])

Check warning on line 60 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "IncrementInteger": @@ @@ $orConditions = []; foreach ($conditions as $column => $value) { - $operator = is_array(value: $value) && array_key_exists(key: 0, array: $value) && is_string(value: $value[0]) + $operator = is_array(value: $value) && array_key_exists(key: 1, array: $value) && is_string(value: $value[0]) ? strtolower(string: $value[0]) : null;
? strtolower(string: $value[0])
: null;

if (!is_array(value: $value) || $operator === null || !in_array(needle: $operator, haystack: self::VALID_OPERATORS, strict: true)) {

Check warning on line 64 in src/OrConditionSpecification.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ ? strtolower(string: $value[0]) : null; - if (!is_array(value: $value) || $operator === null || !in_array(needle: $operator, haystack: self::VALID_OPERATORS, strict: true)) { + if (!is_array(value: $value) && $operator === null || !in_array(needle: $operator, haystack: self::VALID_OPERATORS, strict: true)) { // Not an operator array: keep the value as-is. A plain hash with a // list value (e.g. ['active', 'pending']) becomes a yiisoft IN condition. $orConditions[] = [$column => $value];
// Not an operator array: keep the value as-is. A plain hash with a
// list value (e.g. ['active', 'pending']) becomes a yiisoft IN condition.
$orConditions[] = [$column => $value];
Expand Down
48 changes: 41 additions & 7 deletions src/QueryBuildingVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace Rasuvaeff\Specification;

use Yiisoft\Db\Constant\ColumnType;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Expression\Value\DateTimeValue;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Db\QueryBuilder\Condition\LikeMode;

Expand All @@ -29,7 +31,7 @@ public function visitComparison(ComparisonSpecification $specification): void
case 'between':
case 'not between':
$value = $specification->getValue();
if (!is_array(value: $value) || count(value: $value) !== 2) {
if (!is_array(value: $value) || !array_is_list(array: $value) || count(value: $value) !== 2) {
throw new \InvalidArgumentException(
message: sprintf('%s operator requires array with exactly two values', strtoupper(string: $operator)),
);
Expand All @@ -53,7 +55,7 @@ public function visitComparison(ComparisonSpecification $specification): void
$this->query->andWhere([
$operator === 'is' ? '=' : '!=',
$specification->getColumn(),
$specification->getValue(),
$this->normalizeValue(value: $specification->getValue()),
]);
break;

Expand All @@ -74,7 +76,13 @@ public function visitComparison(ComparisonSpecification $specification): void
private function normalizeValue(mixed $value): mixed
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
return new DateTimeValue(value: $value, type: ColumnType::DATETIMETZ, info: ['size' => 6]);
}

if (is_array(value: $value)) {
foreach ($value as $key => $nestedValue) {
$value[$key] = $this->normalizeValue(value: $nestedValue);
}
}

return $value;
Expand Down Expand Up @@ -126,6 +134,16 @@ public function visitOr(OrSpecification $specification): void
$visitor = new self($subQuery);
$childSpecification->accept($visitor);

if ($subQuery->getOrderBy() !== []) {
$this->query->addOrderBy($subQuery->getOrderBy());
}
if (($limit = $subQuery->getLimit()) !== null) {
$this->query->limit($limit);
}
if (($offset = $subQuery->getOffset()) !== null) {
$this->query->offset($offset);
}

$where = $subQuery->getWhere();
if ($where === null) {
continue;
Expand Down Expand Up @@ -173,23 +191,36 @@ public function visitOrCondition(OrConditionSpecification $specification): void
private function normalizeOrCondition(array $condition): array
{
if (!array_is_list(array: $condition) || count(value: $condition) !== 3) {
return $condition;
return $this->normalizeCondition(condition: $condition);
}
[$operator, $column, $value] = $condition;
if (!is_string(value: $operator) || !is_string(value: $column)) {
return $condition;
return $this->normalizeCondition(condition: $condition);
}
$operator = mb_strtolower(string: $operator);
if ($operator === 'is' || $operator === 'is not') {
return [$operator === 'is' ? '=' : '!=', $column, $value];
return [$operator === 'is' ? '=' : '!=', $column, $this->normalizeValue(value: $value)];
}
if (!is_string(value: $value) || !in_array(needle: $operator, haystack: self::LIKE_OPERATORS, strict: true)) {
return $condition;
return $this->normalizeCondition(condition: $condition);
}

return $this->likeCondition($operator, $column, $value, LikeMatch::Pattern);
}

/**
* @param array<array-key, mixed> $condition
* @return array<array-key, mixed>
*/
private function normalizeCondition(array $condition): array
{
foreach ($condition as $key => $value) {
$condition[$key] = $this->normalizeValue(value: $value);
}

return $condition;
}

/**
* yiisoft/db reads `mode`, `escape` and `caseSensitive` from named operands
* only and defaults to escaping the value and wrapping it in `%…%`. A
Expand Down Expand Up @@ -368,6 +399,9 @@ private function createSubQuery(): QueryInterface
$subQuery = clone $this->query;
$subQuery->setWhere(null);
$subQuery->params([]);
$subQuery->orderBy([]);
$subQuery->limit(null);
$subQuery->offset(null);

return $subQuery;
}
Expand Down
Loading
Loading