Skip to content

Commit

Permalink
collection: fix array improperly processing join conditions as indepe…
Browse files Browse the repository at this point in the history
…ndent
  • Loading branch information
hrach committed Dec 11, 2021
1 parent 50eea7e commit e22e0b5
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 12 deletions.
16 changes: 16 additions & 0 deletions src/Collection/Aggregations/AnyAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@
*/
class AnyAggregator implements IDbalAggregator, IArrayAggregator
{
/** @var string */
private $aggregateKey;


public function __construct(string $aggregateKey = 'any')
{
$this->aggregateKey = $aggregateKey;
}


public function getAggregateKey(): string
{
return $this->aggregateKey;
}


public function aggregateValues(array $values): bool
{
foreach ($values as $value) {
Expand Down
89 changes: 89 additions & 0 deletions src/Collection/Aggregations/CountAggregator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php declare(strict_types = 1);

namespace Nextras\Orm\Collection\Aggregations;


use Nextras\Dbal\QueryBuilder\QueryBuilder;
use Nextras\Orm\Collection\Helpers\DbalExpressionResult;
use Nextras\Orm\Collection\Helpers\DbalJoinEntry;
use Nextras\Orm\Exception\InvalidStateException;


/**
* @implements IArrayAggregator<bool>
*/
class CountAggregator implements IDbalAggregator, IArrayAggregator
{
/** @var int */
private $atLeast;

/** @var int */
private $atMost;

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


public function __construct(
int $atLeast,
int $atMost,
string $aggregateKey = 'count'
)
{
$this->atLeast = $atLeast;
$this->atMost = $atMost;
$this->aggregateKey = $aggregateKey;
}


public function getAggregateKey(): string
{
return $this->aggregateKey;
}


public function aggregateValues(array $values): bool
{
$count = count(array_filter($values));
return $count >= $this->atLeast && $count <= $this->atMost;
}


public function aggregateExpression(
QueryBuilder $queryBuilder,
DbalExpressionResult $expression
): DbalExpressionResult
{
$joinExpression = $expression->expression;

$joinArgs = $expression->args;
$joins = $expression->joins;
$join = array_pop($joins);
if ($join === null) {
throw new InvalidStateException('Aggregation applied over expression without a relationship');
}

$joins[] = new DbalJoinEntry(
$join->toExpression,
$join->toArgs,
$join->toAlias,
"($join->onExpression) AND $joinExpression",
array_merge($join->onArgs, $joinArgs),
$join->conventions
);

$primaryKey = $join->conventions->getStoragePrimaryKey()[0];
$groupBy = $expression->groupBy;

return new DbalExpressionResult(
'COUNT(%table.%column) >= %i AND COUNT(%table.%column) <= %i',
[$join->toAlias, $primaryKey, $this->atLeast, $join->toAlias, $primaryKey, $this->atMost],
$joins,
$groupBy,
null,
true,
null,
null
);
}
}
1 change: 1 addition & 0 deletions src/Collection/Aggregations/IAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

interface IAggregator
{
public function getAggregateKey(): string;
}
17 changes: 16 additions & 1 deletion src/Collection/Aggregations/NoneAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,29 @@
use Nextras\Orm\Exception\InvalidStateException;
use function array_merge;
use function array_pop;
use function array_shift;


/**
* @implements IArrayAggregator<bool>
*/
class NoneAggregator implements IDbalAggregator, IArrayAggregator
{
/** @var string */
private $aggregateKey;


public function __construct(string $aggregateKey = 'none')
{
$this->aggregateKey = $aggregateKey;
}


public function getAggregateKey(): string
{
return $this->aggregateKey;
}


public function aggregateValues(array $values): bool
{
foreach ($values as $value) {
Expand Down
6 changes: 6 additions & 0 deletions src/Collection/Functions/BaseAggregateFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ public function processQueryBuilderExpression(
public $sqlFunction;


public function getAggregateKey(): string
{
return '_' . strtolower($this->sqlFunction);
}


public function aggregateExpression(
QueryBuilder $queryBuilder,
DbalExpressionResult $expression
Expand Down
50 changes: 47 additions & 3 deletions src/Collection/Functions/ConjunctionOperatorFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,57 @@ public function processArrayExpression(
$aggregator = $newAggregator;
}

/**
* The following code evaluates all operands of the AND operator and group them by their aggregators.
* If there is an aggregation, operand results to multi-value result.
* Then we apply the operator's function per each value of the for multi-value result of operands with the same
* aggregation.
*/

/** @var array<string, IArrayAggregator> $aggregators */
$aggregators = [];
$values = [];
$sizes = [];

foreach ($normalized as $arg) {
$callback = $helper->createFilter($arg, $aggregator);
$valueReference = $callback($entity);
$valueReference = $valueReference->applyAggregator();
if ($valueReference->value == false) { // intentionally ==
if ($valueReference->aggregator === null) {
if ($valueReference->value == false) {
return new ArrayPropertyValueReference(
/* $result = */false,
null,
null
);
}
} else {
$key = $valueReference->aggregator->getAggregateKey();
$aggregators[$key] = $valueReference->aggregator;
$values[$key][] = $valueReference->value;
$sizes[$key] = max($sizes[$key] ?? 0, count($valueReference->value));
}
}

foreach (array_keys($aggregators) as $key) {
$valuesBatch = [];
$size = $sizes[$key];
for ($i = 0; $i < $size; $i++) {
$operands = [];
foreach ($values[$key] as $value) {
if (isset($value[$i])) {
$operands[] = $value[$i];
}
}
$valuesBatch[] = array_reduce($operands, function ($v, $ac) {
return $v && $ac;
}, true);
}

$aggregator = $aggregators[$key];
$result = $aggregator->aggregateValues($valuesBatch);
if ($result == false) {
return new ArrayPropertyValueReference(
/* $result = */ false,
/* $result = */false,
null,
null
);
Expand Down
43 changes: 40 additions & 3 deletions src/Collection/Functions/DisjunctionOperatorFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,50 @@ public function processArrayExpression(
$aggregator = $newAggregator;
}

/** @var array<string, IArrayAggregator> $aggregators */
$aggregators = [];
$values = [];
$sizes = [];

foreach ($normalized as $arg) {
$callback = $helper->createFilter($arg, $aggregator);
$valueReference = $callback($entity);
$valueReference = $valueReference->applyAggregator();
if ($valueReference->value == true) { // intentionally ==
if ($valueReference->aggregator === null) {
if ($valueReference->value == true) {
return new ArrayPropertyValueReference(
/* $result = */true,
null,
null
);
}
} else {
$key = $valueReference->aggregator->getAggregateKey();
$aggregators[$key] = $valueReference->aggregator;
$values[$key][] = $valueReference->value;
$sizes[$key] = max($sizes[$key] ?? 0, count($valueReference->value));
}
}

foreach (array_keys($aggregators) as $key) {
$valuesBatch = [];
$size = $sizes[$key];
for ($i = 0; $i < $size; $i++) {
$operands = [];
foreach ($values[$key] as $value) {
if (isset($value[$i])) {
$operands[] = $value[$i];
}
}
$valuesBatch[] = array_reduce($operands, function ($v, $ac) {
return $v || $ac;
}, false);
}

$aggregator = $aggregators[$key];
$result = $aggregator->aggregateValues($valuesBatch);
if ($result == true) {
return new ArrayPropertyValueReference(
/* $result = */ true,
/* $result = */true,
null,
null
);
Expand Down
14 changes: 9 additions & 5 deletions src/Collection/Helpers/ArrayCollectionHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,16 +234,20 @@ public function __toString()
$propertyName = array_shift($tokens);
assert($propertyName !== null);
$propertyMeta = $entityMeta->getProperty($propertyName); // check if property exists
$value = $value->hasValue($propertyName) ? $value->getValue($propertyName) : null;
// We allow to cycle-through even if $value is null to properly detect $isMultiValue
// to return related aggregator.
$value = $value !== null && $value->hasValue($propertyName) ? $value->getValue($propertyName) : null;

if ($propertyMeta->relationship) {
$entityMeta = $propertyMeta->relationship->entityMetadata;
$type = $propertyMeta->relationship->type;
if ($type === PropertyRelationshipMetadata::MANY_HAS_MANY || $type === PropertyRelationshipMetadata::ONE_HAS_MANY) {
$isMultiValue = true;
foreach ($value as $subEntity) {
if ($subEntity instanceof $entityMeta->className) {
$stack[] = [$subEntity, $tokens, $entityMeta];
if ($value !== null) {
foreach ($value as $subEntity) {
if ($subEntity instanceof $entityMeta->className) {
$stack[] = [$subEntity, $tokens, $entityMeta];
}
}
}
continue 2;
Expand All @@ -252,7 +256,7 @@ public function __toString()
assert($propertyMeta->args !== null);
$entityMeta = $propertyMeta->args[EmbeddableContainer::class]['metadata'];
}
} while (count($tokens) > 0 && $value !== null);
} while (count($tokens) > 0);

$values[] = $this->normalizeValue($value, $propertyMeta, false);
} while (count($stack) > 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace NextrasTests\Orm\Integration\Collection;


use Nextras\Orm\Collection\Aggregations\AnyAggregator;
use Nextras\Orm\Collection\Aggregations\CountAggregator;
use Nextras\Orm\Collection\Aggregations\NoneAggregator;
use Nextras\Orm\Collection\ICollection;
use NextrasTests\Orm\DataTestCase;
Expand Down Expand Up @@ -46,6 +47,41 @@ class CollectionAggregationJoinTest extends DataTestCase
}


public function testAnyDependent()
{
/*
* Select author that has a book that:
* - has title Book 1
* - and is not translated.
*/
$authors = $this->orm->authors->findBy([
ICollection::AND,
'books->title' => 'Book 1',
'books->translator->id' => null,
]);
$authors->fetchAll();
Assert::same(0, $authors->count());
Assert::same(0, $authors->countStored());

/*
* Select author that has exactly 1 book that:
* - has been translated
* - or has a price lower than 100.
*
* This test covers dependent comparison in OR operator function.
*/
$authors = $this->orm->authors->findBy([
ICollection::OR,
new CountAggregator(1, 1),
'books->translator->id!=' => null,
'books->price->cents<' => 100,
]);
$authors->fetchAll();
Assert::same(1, $authors->count());
Assert::same(1, $authors->countStored());
}


public function testNone(): void
{
$authors = $this->orm->authors->findBy([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,20 @@ class RelationshipManyHasManyTest extends DataTestCase
]
)->orderBy('id');
Assert::same([1, 4], $books->fetchPairs(null, 'id'));

$tag5 = new Tag('Tag 5');
$book4 = $this->orm->books->getByIdChecked(4);
$book4->tags->add($tag5);
$this->orm->persistAndFlush($tag5);

$books = $this->orm->books->findBy(
[
ICollection::AND,
'tags->name' => 'Tag 5',
'nextPart->tags->name' => 'Tag 3',
]
)->orderBy('id');
Assert::same([4], $books->fetchPairs(null, 'id'));
}


Expand Down

0 comments on commit e22e0b5

Please sign in to comment.