Skip to content

Commit

Permalink
Add array and json overlaps conditions (#855)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tigrov authored Jul 4, 2024
1 parent 37d36b8 commit 3a06023
Show file tree
Hide file tree
Showing 11 changed files with 268 additions and 7 deletions.
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],
];
}
}

0 comments on commit 3a06023

Please sign in to comment.