Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add array and json overlaps conditions #855

Merged
merged 12 commits into from
Jul 4, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
- Chg #846: Remove `SchemaInterface::isReadQuery()` and `AbstractSchema::isReadQuery()` methods (@Tigrov)
- Chg #847: Remove `SchemaInterface::getRawTableName()` and `AbstractSchema::getRawTableName()` methods (@Tigrov)
- Enh #852: Add method chaining for column classes (@Tigrov)
- Enh #855: Add array and JSON overlaps conditions (@Tigrov)

## 1.3.0 March 21, 2024

Expand Down
34 changes: 29 additions & 5 deletions docs/guide/en/query/where.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Similar to the `not like` operator except that `OR` is used to concatenate the `
Requires one operand which must be an instance of `Yiisoft\Db\Query\Query` representing the sub-query.
It will build an `EXISTS` (sub-query) expression.

## not exists
### not exists

Similar to the `exists` operator and builds a `NOT EXISTS` (sub-query) expression.

Expand All @@ -237,6 +237,28 @@ $query->where(['=', $column, $value]);
// $value is safe, but $column name won't be encoded!
```

### array overlaps

Checks if the first array contains at least one element from the second array. Currently supported only by PostgreSQL
and equals to `&&` operator.

Requires two operands:

- Operator 1 should be a column name of an array type or DB expression returning an array;
- Operator 2 should be an array, iterator or DB expression returning an array.

For example, `['array overlaps', 'ids', [1, 2, 3]]` will generate `"ids"::text[] && ARRAY[1,2,3]::text[]`.

### JSON overlaps

Checks if the JSON contains at least one element from the array. Currently supported only by PostgreSQL, MySQL and
SQLite.

Requires two operands:

- Operator 1 should be a column name of a JSON type or DB expression returning a JSON;
- Operator 2 should be an array, iterator or DB expression returning an array.

## Object format

Object format is most powerful yet the most complex way to define conditions.
Expand Down Expand Up @@ -272,10 +294,12 @@ Conversion from operator format into object format is performed according
to `Yiisoft\Db\QueryBuilder\AbstractDQLQueryBuilder::conditionClasses` property
that maps operator names to representative class names.

- `AND`, `OR` => `Yiisoft\Db\QueryBuilder\Condition\ConjunctionCondition`.
- `NOT` => `Yiisoft\Db\QueryBuilder\Condition\NotCondition`.
- `IN`, `NOT IN` => `Yiisoft\Db\QueryBuilder\Condition\InCondition`.
- `BETWEEN`, `NOT BETWEEN` => `Yiisoft\Db\QueryBuilder\Condition\BetweenCondition`.
- `AND`, `OR` => `Yiisoft\Db\QueryBuilder\Condition\ConjunctionCondition`;
- `NOT` => `Yiisoft\Db\QueryBuilder\Condition\NotCondition`;
- `IN`, `NOT IN` => `Yiisoft\Db\QueryBuilder\Condition\InCondition`;
- `BETWEEN`, `NOT BETWEEN` => `Yiisoft\Db\QueryBuilder\Condition\BetweenCondition`;
- `ARRAY OVERLAPS` => `Yiisoft\Db\QueryBuilder\Condition\ArrayOverlapsCondition`;
- `JSON OVERLAPS` => `Yiisoft\Db\QueryBuilder\Condition\JsonOverlapsCondition`.

## Appending conditions

Expand Down
2 changes: 2 additions & 0 deletions src/QueryBuilder/AbstractDQLQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ protected function defaultConditionClasses(): array
'OR NOT LIKE' => Condition\LikeCondition::class,
'EXISTS' => Condition\ExistsCondition::class,
'NOT EXISTS' => Condition\ExistsCondition::class,
'ARRAY OVERLAPS' => Condition\ArrayOverlapsCondition::class,
'JSON OVERLAPS' => Condition\JsonOverlapsCondition::class,
];
}

Expand Down
84 changes: 84 additions & 0 deletions src/QueryBuilder/Condition/AbstractOverlapsCondition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\QueryBuilder\Condition;

use Yiisoft\Db\Exception\InvalidArgumentException;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\QueryBuilder\Condition\Interface\OverlapsConditionInterface;

use function is_iterable;
use function is_string;

/**
* The base class for classes representing the array and JSON overlaps conditions.
*/
abstract class AbstractOverlapsCondition implements OverlapsConditionInterface
{
public function __construct(
private string|ExpressionInterface $column,
private iterable|ExpressionInterface $values,
) {
}

public function getColumn(): string|ExpressionInterface
{
return $this->column;
}

public function getValues(): iterable|ExpressionInterface
{
return $this->values;
}

/**
* Creates a condition based on the given operator and operands.
*
* @throws InvalidArgumentException If the number of operands isn't 2.
*/
public static function fromArrayDefinition(string $operator, array $operands): static
{
if (!isset($operands[0], $operands[1])) {
throw new InvalidArgumentException("Operator \"$operator\" requires two operands.");
}

/** @psalm-suppress UnsafeInstantiation */
return new static(
self::validateColumn($operator, $operands[0]),
self::validateValues($operator, $operands[1])
);
}

/**
* Validates the given column to be string or `ExpressionInterface`.
*
* @throws InvalidArgumentException If the column isn't a string or `ExpressionInterface`.
*/
private static function validateColumn(string $operator, mixed $column): string|ExpressionInterface
{
if (is_string($column) || $column instanceof ExpressionInterface) {
return $column;
}

throw new InvalidArgumentException(
"Operator \"$operator\" requires column to be string or ExpressionInterface."
);
}

/**
* Validates the given values to be `iterable` or `ExpressionInterface`.
*
* @throws InvalidArgumentException If the values aren't an `iterable` or `ExpressionInterface`.
*/
private static function validateValues(string $operator, mixed $values): iterable|ExpressionInterface
{
if (is_iterable($values) || $values instanceof ExpressionInterface) {
return $values;
}

throw new InvalidArgumentException(
"Operator \"$operator\" requires values to be iterable or ExpressionInterface."
);
}
}
12 changes: 12 additions & 0 deletions src/QueryBuilder/Condition/ArrayOverlapsCondition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\QueryBuilder\Condition;

/**
* Condition that represents `ARRAY OVERLAPS` operator is used to check if a column of array type overlaps another array.
*/
final class ArrayOverlapsCondition extends AbstractOverlapsCondition
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\QueryBuilder\Condition\Builder;

use Yiisoft\Db\Expression\ExpressionBuilderInterface;
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\QueryBuilder\QueryBuilderInterface;

/**
* The base class for classes building SQL expressions for array and JSON overlaps conditions.
*/
abstract class AbstractOverlapsConditionBuilder implements ExpressionBuilderInterface
{
public function __construct(protected QueryBuilderInterface $queryBuilder)
{
}

protected function prepareColumn(ExpressionInterface|string $column): string
{
if ($column instanceof ExpressionInterface) {
return $this->queryBuilder->buildExpression($column);
}

return $this->queryBuilder->quoter()->quoteColumnName($column);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\QueryBuilder\Condition\Interface;

use Yiisoft\Db\Expression\ExpressionInterface;

/**
* Represents array and JSON overlaps conditions.
*/
interface OverlapsConditionInterface extends ConditionInterface
{
/**
* @return ExpressionInterface|string The column name or an Expression.
*/
public function getColumn(): string|ExpressionInterface;

/**
* @return ExpressionInterface|iterable An array of values that {@see columns} value should overlap.
*/
public function getValues(): iterable|ExpressionInterface;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
interface SimpleConditionInterface extends ConditionInterface
{
/**
* @return ExpressionInterface|string The column name. If it's an array, a composite `IN` condition will be
* generated.
* @return ExpressionInterface|string The column name or an Expression.
*/
public function getColumn(): string|ExpressionInterface;

Expand Down
12 changes: 12 additions & 0 deletions src/QueryBuilder/Condition/JsonOverlapsCondition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\QueryBuilder\Condition;

/**
* Condition that represents `JSON OVERLAPS` operator and is used to check if a column of JSON type overlaps an array.
*/
final class JsonOverlapsCondition extends AbstractOverlapsCondition
{
}
53 changes: 53 additions & 0 deletions tests/AbstractQueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
use Yiisoft\Db\Expression\ExpressionInterface;
use Yiisoft\Db\Query\Query;
use Yiisoft\Db\Query\QueryInterface;
use Yiisoft\Db\QueryBuilder\Condition\ArrayOverlapsCondition;
use Yiisoft\Db\QueryBuilder\Condition\JsonOverlapsCondition;
use Yiisoft\Db\QueryBuilder\Condition\SimpleCondition;
use Yiisoft\Db\Schema\Builder\ColumnInterface;
use Yiisoft\Db\Schema\QuoterInterface;
Expand Down Expand Up @@ -1545,6 +1547,57 @@ public function testsCreateConditionFromArray(): void
);
}

public function testCreateOverlapsConditionFromArray(): void
{
$db = $this->getConnection();
$qb = $db->getQueryBuilder();

$condition = $qb->createConditionFromArray(['array overlaps', 'column', [1, 2, 3]]);

$this->assertInstanceOf(ArrayOverlapsCondition::class, $condition);
$this->assertSame('column', $condition->getColumn());
$this->assertSame([1, 2, 3], $condition->getValues());

$condition = $qb->createConditionFromArray(['json overlaps', 'column', [1, 2, 3]]);

$this->assertInstanceOf(JsonOverlapsCondition::class, $condition);
$this->assertSame('column', $condition->getColumn());
$this->assertSame([1, 2, 3], $condition->getValues());
}

public function testCreateOverlapsConditionFromArrayWithInvalidOperandsCount(): void
{
$db = $this->getConnection();
$qb = $db->getQueryBuilder();

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Operator "JSON OVERLAPS" requires two operands.');

$qb->createConditionFromArray(['json overlaps', 'column']);
}

public function testCreateOverlapsConditionFromArrayWithInvalidColumn(): void
{
$db = $this->getConnection();
$qb = $db->getQueryBuilder();

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Operator "JSON OVERLAPS" requires column to be string or ExpressionInterface.');

$qb->createConditionFromArray(['json overlaps', ['column'], [1, 2, 3]]);
}

public function testCreateOverlapsConditionFromArrayWithInvalidValues(): void
{
$db = $this->getConnection();
$qb = $db->getQueryBuilder();

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Operator "JSON OVERLAPS" requires values to be iterable or ExpressionInterface.');

$qb->createConditionFromArray(['json overlaps', 'column', 1]);
}

/**
* @dataProvider \Yiisoft\Db\Tests\Provider\QueryBuilderProvider::createIndex
*/
Expand Down
23 changes: 23 additions & 0 deletions tests/Provider/QueryBuilderProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace Yiisoft\Db\Tests\Provider;

use ArrayIterator;
use Yiisoft\Db\Command\DataType;
use Yiisoft\Db\Command\Param;
use Yiisoft\Db\Expression\Expression;
use Yiisoft\Db\Expression\JsonExpression;
use Yiisoft\Db\Query\Query;
use Yiisoft\Db\QueryBuilder\Condition\BetweenColumnsCondition;
use Yiisoft\Db\QueryBuilder\Condition\InCondition;
Expand Down Expand Up @@ -1538,4 +1540,25 @@ public static function columnTypes(): array
[new Column('string(100)')],
];
}

public static function overlapsCondition(): array
{
return [
[[], 0],
[[0], 0],
[[1], 1],
[[4], 1],
[[3], 2],
[[0, 1], 1],
[[1, 2], 1],
[[1, 4], 2],
[[0, 1, 2, 3, 4, 5, 6], 2],
[[6, 7, 8, 9], 0],
[new ArrayIterator([0, 1, 2, 7]), 1],
'null' => [[null], 1],
'expression' => [new Expression("'[0,1,2,7]'"), 1],
'json expression' => [new JsonExpression([0,1,2,7]), 1],
'query expression' => [(new Query(static::getDb()))->select(new JsonExpression([0,1,2,7])), 1],
];
}
}