diff --git a/src/Collection/Helpers/DbalAnyAggregator.php b/src/Collection/Aggregations/AnyAggregator.php similarity index 57% rename from src/Collection/Helpers/DbalAnyAggregator.php rename to src/Collection/Aggregations/AnyAggregator.php index 39abac6a..e37858c9 100644 --- a/src/Collection/Helpers/DbalAnyAggregator.php +++ b/src/Collection/Aggregations/AnyAggregator.php @@ -1,18 +1,49 @@ + */ +class AnyAggregator implements IDbalAggregator, IArrayAggregator { - public function aggregate( + /** @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) { + if ($value) { + return true; + } + } + return false; + } + + + public function aggregateExpression( QueryBuilder $queryBuilder, DbalExpressionResult $expression ): DbalExpressionResult @@ -36,12 +67,12 @@ public function aggregate( ); $primaryKey = $join->conventions->getStoragePrimaryKey()[0]; - $queryBuilder->addGroupBy('%table.%column', $join->toAlias, $primaryKey); return new DbalExpressionResult( 'COUNT(%table.%column) > 0', [$join->toAlias, $primaryKey], $joins, + $expression->groupBy, null, true, null, diff --git a/src/Collection/Aggregations/CountAggregator.php b/src/Collection/Aggregations/CountAggregator.php new file mode 100644 index 00000000..f109d3ed --- /dev/null +++ b/src/Collection/Aggregations/CountAggregator.php @@ -0,0 +1,89 @@ + + */ +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 + ); + } +} diff --git a/src/Collection/Aggregations/IAggregator.php b/src/Collection/Aggregations/IAggregator.php new file mode 100644 index 00000000..70c0d7a6 --- /dev/null +++ b/src/Collection/Aggregations/IAggregator.php @@ -0,0 +1,9 @@ + $values + * @return T + */ + function aggregateValues(array $values); +} diff --git a/src/Collection/Aggregations/IDbalAggregator.php b/src/Collection/Aggregations/IDbalAggregator.php new file mode 100644 index 00000000..ccff8276 --- /dev/null +++ b/src/Collection/Aggregations/IDbalAggregator.php @@ -0,0 +1,16 @@ + + */ +class NoneAggregator implements IDbalAggregator, IArrayAggregator { - public function aggregate( + /** @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) { + if ($value) { + return false; + } + } + return true; + } + + + public function aggregateExpression( QueryBuilder $queryBuilder, DbalExpressionResult $expression ): DbalExpressionResult @@ -36,12 +67,12 @@ public function aggregate( ); $primaryKey = $join->conventions->getStoragePrimaryKey()[0]; - $queryBuilder->addGroupBy('%table.%column', $join->toAlias, $primaryKey); return new DbalExpressionResult( 'COUNT(%table.%column) = 0', [$join->toAlias, $primaryKey], $joins, + $expression->groupBy, null, true, null, diff --git a/src/Collection/ArrayCollection.php b/src/Collection/ArrayCollection.php index 2285445a..41d02f7c 100644 --- a/src/Collection/ArrayCollection.php +++ b/src/Collection/ArrayCollection.php @@ -8,6 +8,7 @@ use Iterator; use Nette\Utils\Arrays; use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper; +use Nextras\Orm\Collection\Helpers\ArrayPropertyValueReference; use Nextras\Orm\Collection\Helpers\FetchPairsHelper; use Nextras\Orm\Entity\IEntity; use Nextras\Orm\Exception\InvalidArgumentException; @@ -60,7 +61,7 @@ class ArrayCollection implements ICollection, MemoryCollection /** * @var Closure[] - * @phpstan-var list + * @phpstan-var array */ protected $collectionFilter = []; @@ -294,7 +295,9 @@ protected function processData(): void $data = $this->data; foreach ($this->collectionFilter as $filter) { - $data = array_filter($data, $filter); + $data = array_filter($data, function ($value) use ($filter) { + return $filter($value)->value; + }); } if (count($this->collectionSorter) > 0) { diff --git a/src/Collection/DbalCollection.php b/src/Collection/DbalCollection.php index cc4b558f..7e9ff79b 100644 --- a/src/Collection/DbalCollection.php +++ b/src/Collection/DbalCollection.php @@ -16,6 +16,7 @@ use Nextras\Orm\Mapper\IRelationshipMapper; use function count; use function is_array; +use function str_repeat; /** @@ -313,6 +314,7 @@ public function __clone() public function getQueryBuilder(): QueryBuilder { $joins = []; + $groupBy = []; $helper = $this->getHelper(); $args = $this->filtering; @@ -324,6 +326,7 @@ public function getQueryBuilder(): QueryBuilder null ); $joins = $expression->joins; + $groupBy = $expression->groupBy; if ($expression->isHavingClause) { $this->queryBuilder->andHaving($expression->expression, ...$expression->args); } else { @@ -334,6 +337,7 @@ public function getQueryBuilder(): QueryBuilder foreach ($this->ordering as [$expression, $direction]) { $joins = array_merge($joins, $expression->joins); + $groupBy = array_merge($groupBy, $expression->groupBy); $orderingExpression = $helper->processOrderDirection($expression, $direction); $this->queryBuilder->addOrderBy('%ex', $orderingExpression); } @@ -344,6 +348,13 @@ public function getQueryBuilder(): QueryBuilder $join->applyJoin($this->queryBuilder); } + if (count($groupBy) > 0) { + $this->queryBuilder->groupBy( + '%ex' . str_repeat(', %ex', count($groupBy) - 1), + ...$groupBy + ); + } + return $this->queryBuilder; } diff --git a/src/Collection/Functions/AnyAggregateFunction.php b/src/Collection/Functions/AnyAggregateFunction.php deleted file mode 100644 index a91f1205..00000000 --- a/src/Collection/Functions/AnyAggregateFunction.php +++ /dev/null @@ -1,48 +0,0 @@ -getValue($entity, $args[0], new ArrayAnyAggregator())->value; - } - - - public function processQueryBuilderExpression( - DbalQueryBuilderHelper $helper, - QueryBuilder $builder, - array $args, - ?IDbalAggregator $aggregator = null - ): DbalExpressionResult - { - if ($aggregator !== null) { - throw new InvalidStateException("Cannot apply two aggregations simultaneously."); - } - - return $helper->processPropertyExpr($builder, $args[0], new DbalAnyAggregator()); - } -} diff --git a/src/Collection/Functions/BaseAggregateFunction.php b/src/Collection/Functions/BaseAggregateFunction.php index ebbc81dd..f2b09fe1 100644 --- a/src/Collection/Functions/BaseAggregateFunction.php +++ b/src/Collection/Functions/BaseAggregateFunction.php @@ -4,11 +4,12 @@ use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper; +use Nextras\Orm\Collection\Helpers\ArrayPropertyValueReference; use Nextras\Orm\Collection\Helpers\DbalExpressionResult; use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper; -use Nextras\Orm\Collection\Helpers\IArrayAggregator; -use Nextras\Orm\Collection\Helpers\IDbalAggregator; use Nextras\Orm\Entity\IEntity; use Nextras\Orm\Exception\InvalidArgumentException; use Nextras\Orm\Exception\InvalidStateException; @@ -45,17 +46,21 @@ public function processArrayExpression( IEntity $entity, array $args, ?IArrayAggregator $aggregator = null - ) + ): ArrayPropertyValueReference { assert(count($args) === 1 && is_string($args[0])); $valueReference = $helper->getValue($entity, $args[0], $aggregator); - if (!$valueReference->isMultiValue) { + if ($valueReference->aggregator === null) { throw new InvalidArgumentException('Aggregation has to be called over has many relationship.'); } assert(is_array($valueReference->value)); - return $this->calculateAggregation($valueReference->value); + return new ArrayPropertyValueReference( + $this->calculateAggregation($valueReference->value), + null, + null + ); } @@ -77,7 +82,13 @@ public function processQueryBuilderExpression( public $sqlFunction; - public function aggregate( + public function getAggregateKey(): string + { + return '_' . strtolower($this->sqlFunction); + } + + + public function aggregateExpression( QueryBuilder $queryBuilder, DbalExpressionResult $expression ): DbalExpressionResult @@ -86,6 +97,7 @@ public function aggregate( "{$this->sqlFunction}($expression->expression)", $expression->args, $expression->joins, + $expression->groupBy, null, true, null, diff --git a/src/Collection/Functions/BaseCompareFunction.php b/src/Collection/Functions/BaseCompareFunction.php index a70838df..ecf7dda4 100644 --- a/src/Collection/Functions/BaseCompareFunction.php +++ b/src/Collection/Functions/BaseCompareFunction.php @@ -4,12 +4,12 @@ use Nextras\Dbal\QueryBuilder\QueryBuilder; -use Nextras\Orm\Collection\Helpers\ArrayAnyAggregator; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper; +use Nextras\Orm\Collection\Helpers\ArrayPropertyValueReference; use Nextras\Orm\Collection\Helpers\DbalExpressionResult; use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper; -use Nextras\Orm\Collection\Helpers\IArrayAggregator; -use Nextras\Orm\Collection\Helpers\IDbalAggregator; use Nextras\Orm\Entity\IEntity; use function assert; use function count; @@ -22,7 +22,7 @@ public function processArrayExpression( IEntity $entity, array $args, ?IArrayAggregator $aggregator = null - ) + ): ArrayPropertyValueReference { assert(count($args) === 2); @@ -33,17 +33,24 @@ public function processArrayExpression( $targetValue = $args[1]; } - if ($valueReference->isMultiValue) { + if ($valueReference->aggregator !== null) { $values = array_map( function ($value) use ($targetValue): bool { return $this->evaluateInPhp($value, $targetValue); }, $valueReference->value ); - $aggregator = $valueReference->aggregator ?? new ArrayAnyAggregator(); - return $aggregator->aggregate($values); + return new ArrayPropertyValueReference( + $values, + $valueReference->aggregator, + null + ); } else { - return $this->evaluateInPhp($valueReference->value, $targetValue); + return new ArrayPropertyValueReference( + $this->evaluateInPhp($valueReference->value, $targetValue), + null, + null + ); } } diff --git a/src/Collection/Functions/CompareLikeFunction.php b/src/Collection/Functions/CompareLikeFunction.php index 238849c1..a8556275 100644 --- a/src/Collection/Functions/CompareLikeFunction.php +++ b/src/Collection/Functions/CompareLikeFunction.php @@ -5,12 +5,13 @@ use Nette\Utils\Strings; use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Expression\LikeExpression; use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper; +use Nextras\Orm\Collection\Helpers\ArrayPropertyValueReference; use Nextras\Orm\Collection\Helpers\DbalExpressionResult; use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper; -use Nextras\Orm\Collection\Helpers\IArrayAggregator; -use Nextras\Orm\Collection\Helpers\IDbalAggregator; use Nextras\Orm\Entity\IEntity; use Nextras\Orm\Exception\InvalidStateException; use function preg_quote; @@ -24,7 +25,7 @@ public function processArrayExpression( IEntity $entity, array $args, ?IArrayAggregator $aggregator = null - ) + ): ArrayPropertyValueReference { assert(count($args) === 2); @@ -40,15 +41,24 @@ public function processArrayExpression( $targetValue = $likeExpression->getInput(); } - if ($valueReference->isMultiValue) { - foreach ($valueReference->value as $subValue) { - if ($this->evaluateInPhp($mode, $subValue, $targetValue)) { - return true; - } - } - return false; + if ($valueReference->aggregator !== null) { + $values = array_map( + function ($value) use ($mode, $targetValue): bool { + return $this->evaluateInPhp($mode, $value, $targetValue); + }, + $valueReference->value + ); + return new ArrayPropertyValueReference( + $values, + $valueReference->aggregator, + null + ); } else { - return $this->evaluateInPhp($mode, $valueReference->value, $targetValue); + return new ArrayPropertyValueReference( + $this->evaluateInPhp($mode, $valueReference->value, $targetValue), + null, + null + ); } } diff --git a/src/Collection/Functions/ConjunctionOperatorFunction.php b/src/Collection/Functions/ConjunctionOperatorFunction.php index 3bb8c93d..5923fe3a 100644 --- a/src/Collection/Functions/ConjunctionOperatorFunction.php +++ b/src/Collection/Functions/ConjunctionOperatorFunction.php @@ -4,13 +4,16 @@ use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper; +use Nextras\Orm\Collection\Helpers\ArrayPropertyValueReference; use Nextras\Orm\Collection\Helpers\ConditionParser; use Nextras\Orm\Collection\Helpers\DbalExpressionResult; use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper; -use Nextras\Orm\Collection\Helpers\IArrayAggregator; -use Nextras\Orm\Collection\Helpers\IDbalAggregator; use Nextras\Orm\Entity\IEntity; +use Nextras\Orm\Exception\InvalidArgumentException; +use Nextras\Orm\Exception\InvalidStateException; class ConjunctionOperatorFunction implements IArrayFunction, IQueryBuilderFunction @@ -33,15 +36,77 @@ public function processArrayExpression( IEntity $entity, array $args, ?IArrayAggregator $aggregator = null - ) + ): ArrayPropertyValueReference { - foreach ($this->normalizeFunctions($args) as $arg) { + [$normalized, $newAggregator] = $this->normalizeFunctions($args); + if ($newAggregator !== null) { + if ($aggregator !== null) throw new InvalidStateException("Cannot apply two aggregations simultaneously."); + if (!$newAggregator instanceof IArrayAggregator) throw new InvalidArgumentException('Array requires aggregation instance of IArrayAggregator.'); + $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> $aggregators */ + $aggregators = []; + $values = []; + $sizes = []; + + foreach ($normalized as $arg) { $callback = $helper->createFilter($arg, $aggregator); - if ($callback($entity) == false) { // intentionally == - return false; + $valueReference = $callback($entity); + 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)); } } - return true; + + 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 ($acc, $v): bool { + return $acc && (bool) $v; + }, true); + } + + $aggregator = $aggregators[$key]; + $result = $aggregator->aggregateValues($valuesBatch); + if ($result == false) { + return new ArrayPropertyValueReference( + /* $result = */false, + null, + null + ); + } + } + + return new ArrayPropertyValueReference( + /* $result = */ true, + null, + null + ); } diff --git a/src/Collection/Functions/DisjunctionOperatorFunction.php b/src/Collection/Functions/DisjunctionOperatorFunction.php index 89fbf956..86f8e6ef 100644 --- a/src/Collection/Functions/DisjunctionOperatorFunction.php +++ b/src/Collection/Functions/DisjunctionOperatorFunction.php @@ -4,13 +4,16 @@ use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper; +use Nextras\Orm\Collection\Helpers\ArrayPropertyValueReference; use Nextras\Orm\Collection\Helpers\ConditionParser; use Nextras\Orm\Collection\Helpers\DbalExpressionResult; use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper; -use Nextras\Orm\Collection\Helpers\IArrayAggregator; -use Nextras\Orm\Collection\Helpers\IDbalAggregator; use Nextras\Orm\Entity\IEntity; +use Nextras\Orm\Exception\InvalidArgumentException; +use Nextras\Orm\Exception\InvalidStateException; class DisjunctionOperatorFunction implements IArrayFunction, IQueryBuilderFunction @@ -33,15 +36,70 @@ public function processArrayExpression( IEntity $entity, array $args, ?IArrayAggregator $aggregator = null - ) + ): ArrayPropertyValueReference { - foreach ($this->normalizeFunctions($args) as $arg) { + [$normalized, $newAggregator] = $this->normalizeFunctions($args); + if ($newAggregator !== null) { + if ($aggregator !== null) throw new InvalidStateException("Cannot apply two aggregations simultaneously."); + if (!$newAggregator instanceof IArrayAggregator) throw new InvalidArgumentException('Array requires aggregation instance of IArrayAggregator.'); + $aggregator = $newAggregator; + } + + /** @var array> $aggregators */ + $aggregators = []; + $values = []; + $sizes = []; + + foreach ($normalized as $arg) { $callback = $helper->createFilter($arg, $aggregator); - if ($callback($entity) == true) { // intentionally == - return true; + $valueReference = $callback($entity); + 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 ($acc, $v) { + return $acc || (bool) $v; + }, false); + } + + $aggregator = $aggregators[$key]; + $result = $aggregator->aggregateValues($valuesBatch); + if ($result == true) { + return new ArrayPropertyValueReference( + /* $result = */true, + null, + null + ); } } - return false; + + return new ArrayPropertyValueReference( + /* $result = */ false, + null, + null + ); } diff --git a/src/Collection/Functions/IArrayFunction.php b/src/Collection/Functions/IArrayFunction.php index f797afcc..cd6d8f1c 100644 --- a/src/Collection/Functions/IArrayFunction.php +++ b/src/Collection/Functions/IArrayFunction.php @@ -3,8 +3,9 @@ namespace Nextras\Orm\Collection\Functions; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; use Nextras\Orm\Collection\Helpers\ArrayCollectionHelper; -use Nextras\Orm\Collection\Helpers\IArrayAggregator; +use Nextras\Orm\Collection\Helpers\ArrayPropertyValueReference; use Nextras\Orm\Entity\IEntity; @@ -20,12 +21,11 @@ interface IArrayFunction * Usually returns a boolean for filtering evaluation. * @phpstan-param array $args * @phpstan-param IArrayAggregator|null $aggregator - * @return mixed */ public function processArrayExpression( ArrayCollectionHelper $helper, IEntity $entity, array $args, ?IArrayAggregator $aggregator = null - ); + ): ArrayPropertyValueReference; } diff --git a/src/Collection/Functions/IQueryBuilderFunction.php b/src/Collection/Functions/IQueryBuilderFunction.php index 9a1b475c..f520aa4b 100644 --- a/src/Collection/Functions/IQueryBuilderFunction.php +++ b/src/Collection/Functions/IQueryBuilderFunction.php @@ -4,9 +4,9 @@ use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Helpers\DbalExpressionResult; use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper; -use Nextras\Orm\Collection\Helpers\IDbalAggregator; /** diff --git a/src/Collection/Functions/JunctionFunctionTrait.php b/src/Collection/Functions/JunctionFunctionTrait.php index caa2f07c..e8c00609 100644 --- a/src/Collection/Functions/JunctionFunctionTrait.php +++ b/src/Collection/Functions/JunctionFunctionTrait.php @@ -4,9 +4,13 @@ use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\IAggregator; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Helpers\DbalExpressionResult; use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper; -use Nextras\Orm\Collection\Helpers\IDbalAggregator; +use Nextras\Orm\Exception\InvalidArgumentException; +use Nextras\Orm\Exception\InvalidStateException; +use function array_shift; /** @@ -17,19 +21,26 @@ trait JunctionFunctionTrait /** * Normalizes directly entered column => value expression to expression array. * @phpstan-param array|list $args - * @phpstan-return list + * @phpstan-return array{list, IAggregator|null} */ protected function normalizeFunctions(array $args): array { + $aggregator = null; + if (($args[0] ?? null) instanceof IAggregator) { + $aggregator = array_shift($args); + } + // Args passed as array values - // [ICollection::AND, ['id' => 1], ['name' => John]] + // Originally called as [ICollection::AND, ['id' => 1], ['name' => John]] + // Currency passed as [['id' => 1], ['name' => John] if (isset($args[0])) { /** @phpstan-var list $args */ - return $args; + return [$args, $aggregator]; } // Args passed as keys - // [ICollection::AND, 'id' => 1, 'name!=' => John] + // Originally called as [ICollection::AND, 'id' => 1, 'name!=' => John] + // Currency passed as ['id' => 1, 'name' => John] /** @phpstan-var array $args */ $processedArgs = []; foreach ($args as $argName => $argValue) { @@ -37,7 +48,7 @@ protected function normalizeFunctions(array $args): array $functionCall[] = $argValue; $processedArgs[] = $functionCall; } - return $processedArgs; + return [$processedArgs, $aggregator]; } @@ -56,12 +67,21 @@ protected function processQueryBuilderExpressionWithModifier( $isHavingClause = false; $processedArgs = []; $joins = []; + $groupBy = []; + + [$normalized, $newAggregator] = $this->normalizeFunctions($args); + if ($newAggregator !== null) { + if ($aggregator !== null) throw new InvalidStateException("Cannot apply two aggregations simultaneously."); + if (!$newAggregator instanceof IDbalAggregator) throw new InvalidArgumentException('Dbal requires aggregation instance of IDbalAggregator.'); + $aggregator = $newAggregator; + } - foreach ($this->normalizeFunctions($args) as $collectionFunctionArgs) { + foreach ($normalized as $collectionFunctionArgs) { $expression = $helper->processFilterFunction($builder, $collectionFunctionArgs, $aggregator); $expression = $expression->applyAggregator($builder); $processedArgs[] = $expression->getExpansionArguments(); $joins = array_merge($joins, $expression->joins); + $groupBy = array_merge($groupBy, $expression->groupBy); $isHavingClause = $isHavingClause || $expression->isHavingClause; } @@ -69,6 +89,7 @@ protected function processQueryBuilderExpressionWithModifier( $dbalModifier, [$processedArgs], $helper->mergeJoins($dbalModifier, $joins), + $groupBy, null, $isHavingClause, null, diff --git a/src/Collection/Functions/NoneAggregateFunction.php b/src/Collection/Functions/NoneAggregateFunction.php deleted file mode 100644 index 9fc89c78..00000000 --- a/src/Collection/Functions/NoneAggregateFunction.php +++ /dev/null @@ -1,48 +0,0 @@ -getValue($entity, $args[0], new ArrayNoneAggregator())->value; - } - - - public function processQueryBuilderExpression( - DbalQueryBuilderHelper $helper, - QueryBuilder $builder, - array $args, - ?IDbalAggregator $aggregator = null - ): DbalExpressionResult - { - if ($aggregator !== null) { - throw new InvalidStateException("Cannot apply two aggregations simultaneously."); - } - - return $helper->processPropertyExpr($builder, $args[0], new DbalNoneAggregator()); - } -} diff --git a/src/Collection/Helpers/ArrayAnyAggregator.php b/src/Collection/Helpers/ArrayAnyAggregator.php deleted file mode 100644 index 8498d675..00000000 --- a/src/Collection/Helpers/ArrayAnyAggregator.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -class ArrayAnyAggregator implements IArrayAggregator -{ - public function aggregate(array $values): bool - { - foreach ($values as $value) { - if ($value) { - return true; - } - } - return false; - } -} diff --git a/src/Collection/Helpers/ArrayCollectionHelper.php b/src/Collection/Helpers/ArrayCollectionHelper.php index 7cecfdd9..0b4fc2fc 100644 --- a/src/Collection/Helpers/ArrayCollectionHelper.php +++ b/src/Collection/Helpers/ArrayCollectionHelper.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use DateTimeInterface; use Nette\Utils\Arrays; +use Nextras\Orm\Collection\Aggregations\AnyAggregator; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; use Nextras\Orm\Collection\Functions\IArrayFunction; use Nextras\Orm\Collection\ICollection; use Nextras\Orm\Entity\Embeddable\EmbeddableContainer; @@ -43,7 +45,7 @@ public function __construct(IRepository $repository) /** * @phpstan-param array|list $expr * @phpstan-param IArrayAggregator|null $aggregator - * @phpstan-return Closure(IEntity): mixed + * @phpstan-return Closure(IEntity): ArrayPropertyValueReference */ public function createFilter(array $expr, ?IArrayAggregator $aggregator): Closure { @@ -90,8 +92,8 @@ public function createSorter(array $expressions): Closure foreach ($parsedExpressions as $expression) { if ($expression[0] instanceof IArrayFunction) { assert(is_array($expression[2])); - $_a = $expression[0]->processArrayExpression($this, $a, $expression[2]); - $_b = $expression[0]->processArrayExpression($this, $b, $expression[2]); + $_a = $expression[0]->processArrayExpression($this, $a, $expression[2])->value; + $_b = $expression[0]->processArrayExpression($this, $b, $expression[2])->value; } else { assert($expression[2] instanceof EntityMetadata); $_a = $this->getValueByTokens($a, $expression[0], $expression[2], null)->value; @@ -134,8 +136,7 @@ public function getValue(IEntity $entity, $expr, ?IArrayAggregator $aggregator): if (!$collectionFunction instanceof IArrayFunction) { throw new InvalidStateException("Collection function $function has to implement " . IArrayFunction::class . ' interface.'); } - $value = $collectionFunction->processArrayExpression($this, $entity, $expr, $aggregator); - return new ArrayPropertyValueReference($value, false, null, null); + return $collectionFunction->processArrayExpression($this, $entity, $expr, $aggregator); } [$tokens, $sourceEntityClassName] = $this->repository->getConditionParser()->parsePropertyExpr($expr); @@ -212,7 +213,6 @@ public function __toString() return "undefined"; } }, - false, null, null ); @@ -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; @@ -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); @@ -264,9 +268,8 @@ public function __toString() return new ArrayPropertyValueReference( $isMultiValue ? $values : $values[0], - $isMultiValue, - $propertyMeta, - $isMultiValue ? ($aggregator ?? new ArrayAnyAggregator()) : null + $isMultiValue ? ($aggregator ?? new AnyAggregator()) : null, + $propertyMeta ); } } diff --git a/src/Collection/Helpers/ArrayNoneAggregator.php b/src/Collection/Helpers/ArrayNoneAggregator.php deleted file mode 100644 index b60715d0..00000000 --- a/src/Collection/Helpers/ArrayNoneAggregator.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ -class ArrayNoneAggregator implements IArrayAggregator -{ - public function aggregate(array $values): bool - { - foreach ($values as $value) { - if ($value) { - return false; - } - } - return true; - } -} diff --git a/src/Collection/Helpers/ArrayPropertyValueReference.php b/src/Collection/Helpers/ArrayPropertyValueReference.php index 3f09ffb5..9718989c 100644 --- a/src/Collection/Helpers/ArrayPropertyValueReference.php +++ b/src/Collection/Helpers/ArrayPropertyValueReference.php @@ -3,7 +3,7 @@ namespace Nextras\Orm\Collection\Helpers; -use Nextras\Orm\Collection\Functions\IArrayFunction; +use Nextras\Orm\Collection\Aggregations\IArrayAggregator; use Nextras\Orm\Entity\Reflection\PropertyMetadata; @@ -19,12 +19,6 @@ class ArrayPropertyValueReference */ public $value; - /** - * Bool if expression evaluated to multiple values (i.e. fetched has-many relationship values). - * @var bool - */ - public $isMultiValue; - /** * Reference to backing property of the expression. * If null, the expression is no more a simple property expression. @@ -44,14 +38,26 @@ class ArrayPropertyValueReference */ public function __construct( $value, - bool $isMultiValue, - ?PropertyMetadata $propertyMetadata, - ?IArrayAggregator $aggregator + ?IArrayAggregator $aggregator, + ?PropertyMetadata $propertyMetadata ) { $this->value = $value; - $this->isMultiValue = $isMultiValue; $this->propertyMetadata = $propertyMetadata; $this->aggregator = $aggregator; } + + + public function applyAggregator(): ArrayPropertyValueReference + { + if ($this->aggregator === null) { + return $this; + } + + return new ArrayPropertyValueReference( + $this->aggregator->aggregateValues($this->value), + null, + null + ); + } } diff --git a/src/Collection/Helpers/DbalExpressionResult.php b/src/Collection/Helpers/DbalExpressionResult.php index abd5c76f..8282b685 100644 --- a/src/Collection/Helpers/DbalExpressionResult.php +++ b/src/Collection/Helpers/DbalExpressionResult.php @@ -4,6 +4,7 @@ use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Entity\Reflection\PropertyMetadata; use Nextras\Orm\Exception\InvalidArgumentException; use function array_unshift; @@ -35,6 +36,12 @@ class DbalExpressionResult */ public $joins; + /** + * List of arguments possible to pass to %ex modifier. + * @var array> + */ + public $groupBy; + /** * @var IDbalAggregator|null */ @@ -65,6 +72,7 @@ class DbalExpressionResult * @phpstan-param literal-string $expression * @param mixed[] $args * @param DbalJoinEntry[] $joins + * @param array> $groupBy * @phpstan-param list $args * @param bool $isHavingClause */ @@ -72,6 +80,7 @@ public function __construct( string $expression, array $args, array $joins = [], + array $groupBy = [], ?IDbalAggregator $aggregator = null, bool $isHavingClause = false, ?PropertyMetadata $propertyMetadata = null, @@ -82,6 +91,7 @@ public function __construct( $this->args = $args; $this->aggregator = $aggregator; $this->joins = $joins; + $this->groupBy = $groupBy; $this->isHavingClause = $isHavingClause; $this->propertyMetadata = $propertyMetadata; $this->valueNormalizer = $valueNormalizer; @@ -130,6 +140,7 @@ public function withArgs(string $expression, array $args): DbalExpressionResult $expression, $args, $this->joins, + $this->groupBy, $this->aggregator, $this->isHavingClause, null, @@ -147,6 +158,6 @@ public function applyAggregator(QueryBuilder $queryBuilder): DbalExpressionResul return $this; } - return $this->aggregator->aggregate($queryBuilder, $this); + return $this->aggregator->aggregateExpression($queryBuilder, $this); } } diff --git a/src/Collection/Helpers/DbalQueryBuilderHelper.php b/src/Collection/Helpers/DbalQueryBuilderHelper.php index 1e4c8b18..fa1ca831 100644 --- a/src/Collection/Helpers/DbalQueryBuilderHelper.php +++ b/src/Collection/Helpers/DbalQueryBuilderHelper.php @@ -7,6 +7,8 @@ use Nette\Utils\Json; use Nextras\Dbal\Platforms\Data\Column; use Nextras\Dbal\QueryBuilder\QueryBuilder; +use Nextras\Orm\Collection\Aggregations\AnyAggregator; +use Nextras\Orm\Collection\Aggregations\IDbalAggregator; use Nextras\Orm\Collection\Functions\ConjunctionOperatorFunction; use Nextras\Orm\Collection\Functions\IQueryBuilderFunction; use Nextras\Orm\Collection\ICollection; @@ -319,7 +321,9 @@ private function processTokens( } if ($makeDistinct) { - $this->makeDistinct($builder, $this->mapper); + $groupBy = $this->makeDistinct($builder, $this->mapper); + } else { + $groupBy = []; } $propertyMetadata = $currentEntityMetadata->getProperty($lastToken); @@ -340,7 +344,8 @@ private function processTokens( '%column', [$column], $joins, - $makeDistinct ? ($aggregator ?? new DbalAnyAggregator()) : null, + $groupBy, + $makeDistinct ? ($aggregator ?? new AnyAggregator()) : null, $makeDistinct, $propertyMetadata, function ($value) use ($propertyMetadata, $currentConventions) { @@ -479,12 +484,10 @@ private function toColumnExpr( /** * @param DbalMapper $mapper + * @return array> */ - private function makeDistinct(QueryBuilder $builder, DbalMapper $mapper): void + private function makeDistinct(QueryBuilder $builder, DbalMapper $mapper): array { - $isGrouped = $builder->getClause('group')[0] ?? null; - if ($isGrouped !== null) return; - $baseTable = $builder->getFromAlias(); if ($this->platformName === 'mssql') { $tableName = $mapper->getTableName(); @@ -492,7 +495,7 @@ private function makeDistinct(QueryBuilder $builder, DbalMapper $mapper): void $columnNames = array_map(function (Column $column) use ($tableName): string { return $tableName . '.' . $column->name; }, $columns); - $builder->groupBy('%column[]', $columnNames); + return [['%column[]', $columnNames]]; } else { $primaryKey = $this->mapper->getConventions()->getStoragePrimaryKey(); @@ -502,7 +505,7 @@ private function makeDistinct(QueryBuilder $builder, DbalMapper $mapper): void $groupBy[] = "{$baseTable}.{$column}"; } - $builder->groupBy('%column[]', $groupBy); + return [['%column[]', $groupBy]]; } } } diff --git a/src/Collection/Helpers/IArrayAggregator.php b/src/Collection/Helpers/IArrayAggregator.php deleted file mode 100644 index 3e604c7c..00000000 --- a/src/Collection/Helpers/IArrayAggregator.php +++ /dev/null @@ -1,16 +0,0 @@ - $values - * @return T - */ - function aggregate(array $values); -} diff --git a/src/Collection/Helpers/IDbalAggregator.php b/src/Collection/Helpers/IDbalAggregator.php deleted file mode 100644 index d9007c90..00000000 --- a/src/Collection/Helpers/IDbalAggregator.php +++ /dev/null @@ -1,15 +0,0 @@ - true, CompareSmallerThanFunction::class => true, CompareLikeFunction::class => true, - AnyAggregateFunction::class => true, AvgAggregateFunction::class => true, CountAggregateFunction::class => true, MaxAggregateFunction::class => true, MinAggregateFunction::class => true, - NoneAggregateFunction::class => true, SumAggregateFunction::class => true, ]; diff --git a/tests/cases/integration/Collection/collection.aggregation.join.phpt b/tests/cases/integration/Collection/collection.aggregation.join.phpt new file mode 100644 index 00000000..8a05d14c --- /dev/null +++ b/tests/cases/integration/Collection/collection.aggregation.join.phpt @@ -0,0 +1,101 @@ +orm->authors->findBy([ + ICollection::AND, + new AnyAggregator(), + ['books->title' => 'Book 1'], + ]); + Assert::same(1, $authors->count()); + Assert::same(1, $authors->countStored()); + $author = $authors->fetch(); + Assert::notNull($author); + Assert::same(1, $author->id); + + // implicit any + $authors = $this->orm->authors->findBy([ + ICollection::AND, + ['books->title' => 'Book 1'], + ]); + Assert::same(1, $authors->count()); + Assert::same(1, $authors->countStored()); + $author = $authors->fetch(); + Assert::notNull($author); + Assert::same(1, $author->id); + } + + + public function testAnyDependent(): void + { + /* + * 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([ + ICollection::AND, + new NoneAggregator(), + ['books->title' => 'Book 1'], + ]); + Assert::same(1, $authors->count()); + Assert::same(1, $authors->countStored()); + $author = $authors->fetch(); + Assert::notNull($author); + Assert::same(2, $author->id); + } +} + + +(new CollectionAggregationJoinTest())->run(); diff --git a/tests/cases/integration/Collection/collection.aggregation.phpt b/tests/cases/integration/Collection/collection.aggregation.phpt index 6e339720..ded7449f 100644 --- a/tests/cases/integration/Collection/collection.aggregation.phpt +++ b/tests/cases/integration/Collection/collection.aggregation.phpt @@ -16,7 +16,6 @@ use Nextras\Orm\Collection\Functions\CompareSmallerThanFunction; use Nextras\Orm\Collection\Functions\CountAggregateFunction; use Nextras\Orm\Collection\Functions\MaxAggregateFunction; use Nextras\Orm\Collection\Functions\MinAggregateFunction; -use Nextras\Orm\Collection\Functions\NoneAggregateFunction; use Nextras\Orm\Collection\Functions\SumAggregateFunction; use Nextras\Orm\Collection\ICollection; use NextrasTests\Orm\Author; @@ -186,20 +185,6 @@ class CollectionAggregationTest extends DataTestCase Assert::same(2, $users->count()); Assert::same(2, $users->countStored()); } - - - public function testNone(): void - { - $users = $this->orm->authors->findBy([ - NoneAggregateFunction::class, - ['books->title' => 'Book 1'], - ]); - Assert::same(1, $users->count()); - Assert::same(1, $users->countStored()); - $user = $users->fetch(); - Assert::notNull($user); - Assert::same(2, $user->id); - } } diff --git a/tests/cases/integration/Relationships/relationships.manyHasMany.phpt b/tests/cases/integration/Relationships/relationships.manyHasMany.phpt index 709c01ab..9d4e402d 100644 --- a/tests/cases/integration/Relationships/relationships.manyHasMany.phpt +++ b/tests/cases/integration/Relationships/relationships.manyHasMany.phpt @@ -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')); }