Skip to content

Commit

Permalink
Merge pull request #653 from nextras/aggregator
Browse files Browse the repository at this point in the history
CoundAggregator optional boundaries
  • Loading branch information
hrach authored Mar 14, 2024
2 parents 7d0eebb + 844a3cf commit aabcd5a
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 47 deletions.
89 changes: 75 additions & 14 deletions docs/collection-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ $orm->books->findBy(['translator->name!=' => 'Jon Snow']);
The described syntax may be expanded to support a `OR` logical conjunction. Prepend the `ICollection::OR` operator as a first value of the filtering array:

```php
// finds all books which were authored or translated by one specific person
// finds all books that were authored or translated by one specific person
$books = $orm->books->findBy([
ICollection::OR,
'author->name' => 'Jon Snow',
Expand All @@ -39,7 +39,7 @@ This relationship filtering is designed mainly for has-one relationship. Has-man
You may nest the filtering structure; use the same syntax repeatedly:

```php
// find all man older than 10 years and woman younger than 12 years
// find all men older than 10 years and woman younger than 12 years
$authors = $orm->author->findBy([
ICollection::OR,
[
Expand All @@ -58,7 +58,7 @@ $authors = $orm->author->findBy([
The previous example can be shortened because the `AND` operator is the default logical operator.

```php
// find all man older than 10 years and woman younger than 12 years
// find all men older than 10 years and woman younger than 12 years
$authors = $orm->author->findBy([
ICollection::OR,
[
Expand All @@ -83,22 +83,83 @@ Filtering over virtual properties is generally unsupported and provides undefine

```php
// finds all users with email hosted on gmail.com
$users->findBy([
'emails~' => LikeExpression::endsWith('@gmail.com'),
$authors = $orm->authors->findBy([
'email~' => LikeExpression::endsWith('@gmail.com'),
]);
```

#### Aggregation
#### Relationship Aggregation

The collection filtering by a relationship was already mentioned earlier. We have described the simple relationship case where the base collection is filtered by a *HasOne relationship. But this may get more complicated with the *HasMany relationships. To do so, we need to aggregate the relationship by using a new instance of the wanted aggregator. Orm comes with these three aggregators:

- `AnyAggregator`,
- `NoneAggregator`.
- `CountAggregator`,

The `AnyAggregator` is implicit; whenever you put filter over *hasMany relationship, the filter uses "any" aggregation. To set an aggregator, pass it as a first argument of the filtering expression, but this time, the collection function name is required. The following example looks for authors who have *ANY* book with price > €10.

```php
use Nextras\Orm\Collection\Aggregations\AnyAggregator;
use Nextras\Orm\Collection\Aggregations\CountAggregator;
use Nextras\Orm\Collection\ICollection;

$authors = $orm->authors->findBy([
'books->price>' => 10,
'books->currency' => 'eur',
]);

// same as

$authors = $orm->authors->findBy([
ICollection::AND,
new AnyAggregator(),
'books->price>' => 10,
'books->currency' => 'eur',
]);
```

Swap with `NoneAggregator` to find authors who do not have any book with price > €10. With `CountAggregator` you may limit the number of required aggregated matches. Let's find all authors who have at least two books with price > €10.

```php
$authors = $orm->authors->findBy([
ICollection::AND,
new CountAggregator(atLeast: 2, atMost: null),
'books->price>' => 10,
'books->currency' => 'eur',
]);
```

Aggregators accept an optional to *grouping key* to allow differentiating the joins. So the following example finds those authors who authored `Book 1` with price `€50` AND `Book 2` with price `€150`.

```php
$authors = $orm->authors->findBy([
ICollection::AND,
[
new AnyAggregator('any1'),
'books->title' => 'Book 1',
'books->price' => 50,
],
[
new AnyAggregator('any2'),
'books->title' => 'Book 2',
'books->price' => 150,
],
]);
```

If those aggregations were not separated, then none of the entries would match because the book's title could not be `Book 1` and `Book 2` at the same time.

#### Property Aggregation

Aggregation functions can be used for both collection filtering and sorting. They are based on [collection functions | collection-functions] -- a general approach for custom collection modification.

Orm brings these prepared aggregation functions:

- CountAggregateFunction
- SumAggregateFunction
- AvgAggregateFunction
- MinAggregateFunction
- MaxAggregateFunction
- `CountAggregateFunction`
- `SumAggregateFunction`
- `AvgAggregateFunction`
- `MinAggregateFunction`
- `MaxAggregateFunction`

All those functions are implemented both for Dbal and Array collections, and they are registered in a repository as commonly provided collection functions.

Expand All @@ -112,7 +173,7 @@ $authorsCollection->orderBy(
);
```

In the example we sort the collection of authors by the count of their books, i.e. authors with the least books will be at the beginning. The example allows the same "property expression" you use for filtering. You can reverse the ordering:
In the example, we sort the collection of authors by the count of their books, i.e., authors with the fewest books will be at the beginning. The example allows the same "property expression" you use for filtering. You can reverse the ordering:

```php
use Nextras\Orm\Collection\Functions\CountAggregateFunction;
Expand All @@ -124,7 +185,7 @@ $authorsCollection->orderBy(
);
```

Filtering by an aggregation requires a little more. Let's filter the collection by authors who have written more than 2 books. Using `CountAggregationFunction` itself won’t be enough. You need to compare its result with the wanted number, `2` this time. To do so, use built-in `Compare*Function`. Choose function depending on the wanted operator. The function takes a property expression on the left, and a value to compare (on the right).
Filtering by an aggregation requires a little more. Let's filter the collection by authors who have written more than two books. Using `CountAggregationFunction` itself won’t be enough. You need to compare its result with the wanted number, `2` this time. To do so, use built-in `Compare*Function`. Choose function depending on the wanted operator. The function takes a property expression on the left, and a value to compare (on the right).

```php
use Nextras\Orm\Collection\Functions\CompareGreaterThanFunction;
Expand All @@ -143,7 +204,7 @@ $authorsCollection->findBy(
);
```

You can nest these function calls together. This approach is very powerful and flexible, though, sometimes quite verbose. To ease this issue you may create your own wrappers (not included in Orm!).
You can nest these function calls together. This approach is very powerful and flexible, though, sometimes quite verbose. To ease this issue, you may create your own wrappers (not included in Orm!).

```php
class Aggregate {
Expand Down
2 changes: 1 addition & 1 deletion docs/default.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Let's create some new entities
$author = new Author();
$author->name = 'Jon Snow';
$author->born = 'yesterday';
$author->mail = '[email protected]';
$author->email = '[email protected]';

$publisher = new Publisher();
$publisher->name = '7K publisher';
Expand Down
79 changes: 50 additions & 29 deletions src/Collection/Aggregations/CountAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Nextras\Orm\Collection\Functions\Result\DbalTableJoin;
use Nextras\Orm\Exception\InvalidArgumentException;
use function array_filter;
use function array_merge;
use function array_pop;
use function count;

Expand All @@ -18,26 +19,18 @@
*/
class CountAggregator implements Aggregator
{
private int $atLeast;

private int $atMost;

/** @var literal-string */
private string $aggregateKey;


/**
* @param literal-string $aggregateKey
*/
public function __construct(
int $atLeast,
int $atMost,
string $aggregateKey = 'count',
private readonly ?int $atLeast,
private readonly ?int $atMost,
private readonly string $aggregateKey = 'count',
)
{
$this->atLeast = $atLeast;
$this->atMost = $atMost;
$this->aggregateKey = $aggregateKey;
if ($this->atLeast === null && $this->atMost === null) {
throw new InvalidArgumentException("At least one of the limitations (\$atLeast or \$atMost) is required.");
}
}


Expand All @@ -50,7 +43,9 @@ public function getAggregateKey(): string
public function aggregateValues(array $values): bool
{
$count = count(array_filter($values));
return $count >= $this->atLeast && $count <= $this->atMost;
if ($this->atLeast !== null && $count >= $this->atLeast) return true;
if ($this->atMost !== null && $count <= $this->atMost) return true;
return false;
}


Expand Down Expand Up @@ -85,19 +80,45 @@ public function aggregateExpression(
groupByColumns: $join->groupByColumns,
);

return new DbalExpressionResult(
expression: 'COUNT(%table.%column) >= %i AND COUNT(%table.%column) <= %i',
args: [
$join->toAlias,
$join->groupByColumns[0],
$this->atLeast,
$join->toAlias,
$join->groupByColumns[0],
$this->atMost,
],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
);
if ($this->atLeast !== null && $this->atMost !== null) {
return new DbalExpressionResult(
expression: 'COUNT(%table.%column) >= %i AND COUNT(%table.%column) <= %i',
args: [
$join->toAlias,
$join->groupByColumns[0],
$this->atLeast,
$join->toAlias,
$join->groupByColumns[0],
$this->atMost,
],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
);
} elseif ($this->atMost !== null) {
return new DbalExpressionResult(
expression: 'COUNT(%table.%column) <= %i',
args: [
$join->toAlias,
$join->groupByColumns[0],
$this->atMost,
],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
);
} else {
return new DbalExpressionResult(
expression: 'COUNT(%table.%column) >= %i',
args: [
$join->toAlias,
$join->groupByColumns[0],
$this->atLeast,
],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ class CollectionAggregationJoinTest extends DataTestCase
'books->title' => 'Book 1',
'books->translator->id' => null,
]);
$authors->fetchAll();
Assert::same(0, $authors->count());
Assert::same(0, $authors->countStored());

Expand All @@ -74,16 +73,35 @@ class CollectionAggregationJoinTest extends DataTestCase
*/
$authors = $this->orm->authors->findBy([
ICollection::OR,
new CountAggregator(1, 1),
new CountAggregator(atLeast: 1, atMost: 1),
'books->translator->id!=' => null,
'books->price->cents<' => 100,
]);
$authors->fetchAll();
Assert::same(1, $authors->count());
Assert::same(1, $authors->countStored());
}


public function testCountAggregator(): void
{
$authors = $this->orm->authors->findBy([
ICollection::AND,
new CountAggregator(atLeast: 2, atMost: null),
'books->price->cents>=' => 50,
]);
Assert::same(1, $authors->count());
Assert::same(1, $authors->countStored());

$authors = $this->orm->authors->findBy([
ICollection::AND,
new CountAggregator(atLeast: null, atMost: 1),
'books->price->cents>=' => 51,
]);
Assert::same(2, $authors->count());
Assert::same(2, $authors->countStored());
}


public function testHasValueOrEmptyWithFunctions(): void
{
/*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
SELECT "authors".* FROM "public"."authors" AS "authors" LEFT JOIN "books" AS "books_count" ON (("authors"."id" = "books_count"."author_id") AND "books_count"."price" >= 50) GROUP BY "authors"."id" HAVING ((COUNT("books_count"."id") >= 2));
SELECT COUNT(*) AS count FROM (SELECT "authors"."id" FROM "public"."authors" AS "authors" LEFT JOIN "books" AS "books_count" ON (("authors"."id" = "books_count"."author_id") AND "books_count"."price" >= 50) GROUP BY "authors"."id" HAVING ((COUNT("books_count"."id") >= 2))) temp;
SELECT "authors".* FROM "public"."authors" AS "authors" LEFT JOIN "books" AS "books_count" ON (("authors"."id" = "books_count"."author_id") AND "books_count"."price" >= 51) GROUP BY "authors"."id" HAVING ((COUNT("books_count"."id") <= 1));
SELECT COUNT(*) AS count FROM (SELECT "authors"."id" FROM "public"."authors" AS "authors" LEFT JOIN "books" AS "books_count" ON (("authors"."id" = "books_count"."author_id") AND "books_count"."price" >= 51) GROUP BY "authors"."id" HAVING ((COUNT("books_count"."id") <= 1))) temp;

0 comments on commit aabcd5a

Please sign in to comment.