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

separate WHERE & HAVING in expression result & fix buggy having rewrite #685

Merged
merged 1 commit into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/Collection/Aggregations/AnyAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,12 @@ public function aggregateExpression(
);

return new DbalExpressionResult(
expression: 'COUNT(%column) > 0',
args: [$join->toPrimaryKey],
expression: null,
args: [],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
havingExpression: 'COUNT(%column) > 0',
havingArgs: [$join->toPrimaryKey],
);
}
}
33 changes: 18 additions & 15 deletions src/Collection/Aggregations/CountAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,38 +76,41 @@ public function aggregateExpression(

if ($this->atLeast !== null && $this->atMost !== null) {
return new DbalExpressionResult(
expression: 'COUNT(%column) >= %i AND COUNT(%column) <= %i',
args: [
expression: null,
args: [],
joins: $joins,
groupBy: $expression->groupBy,
havingExpression: 'COUNT(%column) >= %i AND COUNT(%column) <= %i',
havingArgs: [
$join->toPrimaryKey,
$this->atLeast,
$join->toPrimaryKey,
$this->atMost,
],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
);
} elseif ($this->atMost !== null) {
return new DbalExpressionResult(
expression: 'COUNT(%column) <= %i',
args: [
expression: null,
args: [],
joins: $joins,
groupBy: $expression->groupBy,
havingExpression: 'COUNT(%column) <= %i',
havingArgs: [
$join->toPrimaryKey,
$this->atMost,
],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
);
} else {
return new DbalExpressionResult(
expression: 'COUNT(%column) >= %i',
args: [
expression: null,
args: [],
joins: $joins,
groupBy: $expression->groupBy,
havingExpression: 'COUNT(%column) >= %i',
havingArgs: [
$join->toPrimaryKey,
$this->atLeast,
],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
);
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/Collection/Aggregations/NoneAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,12 @@ public function aggregateExpression(
);

return new DbalExpressionResult(
expression: 'COUNT(%column) = 0',
args: [$join->toPrimaryKey],
expression: null,
args: [],
joins: $joins,
groupBy: $expression->groupBy,
isHavingClause: true,
havingExpression: 'COUNT(%column) = 0',
havingArgs: [$join->toPrimaryKey],
);
}
}
7 changes: 4 additions & 3 deletions src/Collection/Aggregations/NumericAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ public function aggregateExpression(
): DbalExpressionResult
{
return new DbalExpressionResult(
expression: "{$this->dbalAggregationFunction}($expression->expression)",
args: $expression->args,
expression: null,
args: [],
joins: $expression->joins,
groupBy: $expression->groupBy,
isHavingClause: true,
havingExpression: "{$this->dbalAggregationFunction}($expression->expression)",
havingArgs: $expression->args,
);
}
}
7 changes: 4 additions & 3 deletions src/Collection/DbalCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,12 @@ public function getQueryBuilder(): QueryBuilder
);
$joins = $expression->joins;
$groupBy = $expression->groupBy;
if ($expression->isHavingClause) {
$this->queryBuilder->andHaving($expression->expression, ...$expression->args);
} else {
if ($expression->expression !== null) {
$this->queryBuilder->andWhere($expression->expression, ...$expression->args);
}
if ($expression->havingExpression !== null) {
$this->queryBuilder->andHaving($expression->havingExpression, ...$expression->havingArgs);
}
if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) {
$this->applyGroupByWithSameNamedColumnsWorkaround($this->queryBuilder, $groupBy);
}
Expand Down
2 changes: 0 additions & 2 deletions src/Collection/Expression/ExpressionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@


/**
* @internal
*
* Determines if the expression is processed for AND subtree, OR subtree or as a pure expression,
* e.g., a sorting expression.
*
Expand Down
2 changes: 1 addition & 1 deletion src/Collection/Functions/CompareEqualsFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ protected function evaluateInDb(
if (count($value) > 0) {
// Multi-column primary key handling
// Construct multiOr simplification as array{list<Fqn>, modifiers: list<string>, values: list<list<mixed>>}
$args = $expression->getArgumentsForExpansion();
$args = $expression->getArgsForExpansion();
if (count($args) === 2 && $args[0] === '%column' && is_array($args[1]) && is_array($modifier)) {
$columns = $args[1];
$data = [];
Expand Down
2 changes: 1 addition & 1 deletion src/Collection/Functions/CompareNotEqualsFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ protected function evaluateInDb(
if (count($value) > 0) {
// Multi-column primary key handling
// Construct multiOr simplification as array{list<Fqn>, modifiers: list<string>, values: list<list<mixed>>}
$args = $expression->getArgumentsForExpansion();
$args = $expression->getArgsForExpansion();
if (count($args) === 2 && $args[0] === '%column' && is_array($args[1]) && is_array($modifier)) {
$columns = $args[1];
$data = [];
Expand Down
42 changes: 31 additions & 11 deletions src/Collection/Functions/JunctionFunctionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ protected function processQueryBuilderExpressionWithModifier(
?Aggregator $aggregator,
): DbalExpressionResult
{
$isHavingClause = false;
$processedArgs = [];
$processedHavingArgs = [];
$joins = [];
$groupBy = [];
$columns = [];
Expand All @@ -80,20 +80,40 @@ protected function processQueryBuilderExpressionWithModifier(
foreach ($normalized as $collectionFunctionArgs) {
$expression = $helper->processExpression($builder, $collectionFunctionArgs, $context, $aggregator);
$expression = $expression->applyAggregator($builder, $context);
$processedArgs[] = $expression->getArgumentsForExpansion();
$whereArgs = $expression->getArgsForExpansion();
if ($whereArgs !== []) {
$processedArgs[] = $whereArgs;
}
$havingArgs = $expression->getHavingArgsForExpansion();
if ($havingArgs !== []) {
$processedHavingArgs[] = $havingArgs;
}
$joins = array_merge($joins, $expression->joins);
$groupBy = array_merge($groupBy, $expression->groupBy);
$columns = array_merge($columns, $expression->columns);
$isHavingClause = $isHavingClause || $expression->isHavingClause;
}

return new DbalExpressionResult(
expression: $dbalModifier,
args: [$processedArgs],
joins: $helper->mergeJoins($dbalModifier, $joins),
groupBy: $isHavingClause ? array_merge($groupBy, $columns) : $groupBy,
columns: $isHavingClause ? [] : $columns,
isHavingClause: $isHavingClause,
);
if ($context === ExpressionContext::FilterOr && $processedHavingArgs !== []) {
// move all where expressions to HAVING clause
return new DbalExpressionResult(
expression: null,
args: [],
joins: $helper->mergeJoins($dbalModifier, $joins),
groupBy: array_merge($groupBy, $columns),
havingExpression: $dbalModifier,
havingArgs: [array_merge($processedArgs, $processedHavingArgs)],
columns: [],
);
} else {
return new DbalExpressionResult(
expression: $processedArgs === [] ? null : $dbalModifier,
args: $processedArgs === [] ? [] : [$processedArgs],
joins: $helper->mergeJoins($dbalModifier, $joins),
groupBy: $groupBy,
havingExpression: $processedHavingArgs === [] ? null : $dbalModifier,
havingArgs: $processedHavingArgs === [] ? [] : [$processedHavingArgs],
columns: $columns,
);
}
}
}
72 changes: 64 additions & 8 deletions src/Collection/Functions/Result/DbalExpressionResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Nextras\Orm\Collection\Aggregations\Aggregator;
use Nextras\Orm\Collection\Expression\ExpressionContext;
use Nextras\Orm\Entity\Reflection\PropertyMetadata;
use Nextras\Orm\Exception\InvalidStateException;
use function array_unshift;
use function array_values;

Expand All @@ -31,25 +32,27 @@ class DbalExpressionResult


/**
* @param literal-string $expression Holds expression separately from its arguments. Put Dbal's modifiers into the expression and arguments separately.
* @param literal-string|null $expression Holds expression separately from its arguments. Put Dbal's modifiers into the expression and arguments separately.
* @param list<mixed> $args Expression's arguments.
* @param list<DbalTableJoin> $joins
* @param list<Fqn> $groupBy List of columns used for grouping.
* @param literal-string|null $havingExpression Holds expression for HAVING clause separately from its arguments. Put Dbal's modifiers into the expression and arguments separately.
* @param list<mixed> $havingArgs HAVING clause expression's arguments.
* @param list<Fqn> $columns List of columns used in the expression. If needed, this is later used to properly reference in GROUP BY clause.
* @param Aggregator<mixed>|null $aggregator Result aggregator that is applied later.
* @param bool $isHavingClause True if the expression represents HAVING clause instead of WHERE clause.
* @param PropertyMetadata|null $propertyMetadata Reference to backing property of the expression. If null, the expression is no more a simple property expression.
* @param (callable(mixed): mixed)|null $valueNormalizer Normalizes the value for better PHP comparison, it considers the backing property type.
* @param literal-string|list<literal-string|null>|null $dbalModifier Dbal modifier for particular column. Array if multi-column. Null value means expression is a general expression.
*/
public function __construct(
public readonly string $expression,
public readonly string|null $expression,
public readonly array $args,
public readonly array $joins = [],
public readonly array $groupBy = [],
public readonly string|null $havingExpression = null,
public readonly array $havingArgs = [],
public readonly array $columns = [],
public readonly ?Aggregator $aggregator = null,
public readonly bool $isHavingClause = false,
public readonly ?PropertyMetadata $propertyMetadata = null,
?callable $valueNormalizer = null,
public readonly string|array|null $dbalModifier = null,
Expand All @@ -62,13 +65,29 @@ public function __construct(
/**
* Appends SQL expression to the original expression.
* If you need prepend or other complex expression, create new instance of DbalExpressionResult.
*
* It auto-detects if expression or havingExpression should be appended. If both them are used, it throws exception.
*
* @param literal-string $expression
* @param mixed ...$args
*/
public function append(string $expression, ...$args): DbalExpressionResult
{
$args = array_values(array_merge($this->args, $args));
return $this->withArgs("{$this->expression} $expression", $args);
if ($this->expression !== null && $this->havingExpression !== null) {
throw new InvalidStateException(
'Cannot append result to a DbalExpressionResult because the both $expression (' .
$this->expression . ') and $havingExpression (' . $this->havingExpression . ')' .
'are already defined. Modify expression manually using withArgs() or withHavingArgs(). ',
);
}

if ($this->expression !== null) {
$args = array_values(array_merge($this->args, $args));
return $this->withArgs("{$this->expression} $expression", $args);
} else {
$args = array_values(array_merge($this->havingArgs, $args));
return $this->withHavingArgs("{$this->havingExpression} $expression", $args);
}
}


Expand All @@ -77,14 +96,29 @@ public function append(string $expression, ...$args): DbalExpressionResult
* Suitable as an `%ex` modifier argument.
* @return array<mixed>
*/
public function getArgumentsForExpansion(): array
public function getArgsForExpansion(): array
{
if ($this->expression === null) return [];
$args = $this->args;
array_unshift($args, $this->expression);
return $args;
}


/**
* Returns all HAVING clause arguments including the HAVING expression.
* Suitable as an `%ex` modifier argument.
* @return array<mixed>
*/
public function getHavingArgsForExpansion(): array
{
if ($this->havingExpression === null) return [];
$args = $this->havingArgs;
array_unshift($args, $this->havingExpression);
return $args;
}


/**
* Creates a new DbalExpression from the passed $args and keeps the original expression
* properties (joins, aggregator, ...).
Expand All @@ -98,9 +132,31 @@ public function withArgs(string $expression, array $args): DbalExpressionResult
args: $args,
joins: $this->joins,
groupBy: $this->groupBy,
havingExpression: $this->havingExpression,
havingArgs: $this->havingArgs,
columns: $this->columns,
aggregator: $this->aggregator,
);
}


/**
* Creates a new DbalExpression from the passed $havingArgs and keeps the original having expression
* properties (joins, aggregator, ...).
* @param literal-string $havingExpression
* @param list<mixed> $havingArgs
*/
public function withHavingArgs(string $havingExpression, array $havingArgs): DbalExpressionResult
{
return new DbalExpressionResult(
expression: $this->expression,
args: $this->args,
joins: $this->joins,
groupBy: $this->groupBy,
havingExpression: $havingExpression,
havingArgs: $havingArgs,
columns: $this->columns,
aggregator: $this->aggregator,
isHavingClause: $this->isHavingClause,
);
}

Expand Down
6 changes: 5 additions & 1 deletion src/Collection/Helpers/DbalQueryBuilderHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ public function processExpression(
*/
public function processOrderDirection(DbalExpressionResult $expression, string $direction): array
{
$args = $expression->getArgumentsForExpansion();
if ($expression->expression !== null) {
$args = $expression->getArgsForExpansion();
} else {
$args = $expression->getHavingArgsForExpansion();
}
if ($this->platformName === 'mysql') {
if ($direction === ICollection::ASC || $direction === ICollection::ASC_NULLS_FIRST) {
return ['%ex ASC', $args];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use Nextras\Orm\Collection\Aggregations\AnyAggregator;
use Nextras\Orm\Collection\Aggregations\CountAggregator;
use Nextras\Orm\Collection\Aggregations\NoneAggregator;
use Nextras\Orm\Collection\Functions\CompareEqualsFunction;
use Nextras\Orm\Collection\Functions\CompareGreaterThanFunction;
use Nextras\Orm\Collection\Functions\CountAggregateFunction;
use Nextras\Orm\Collection\ICollection;
use NextrasTests\Orm\DataTestCase;
Expand Down Expand Up @@ -67,6 +68,32 @@ class CollectionAggregationJoinTest extends DataTestCase
}


public function testIndependentAnyWithGroupingOverPk(): void
{
// Select books that:
// - have tags 2 OR 3
// - AND have more than 1 tags
// Matches books #1, #2
$books = $this->orm->books->findBy([
ICollection::AND,
['tags->id' => [2, 3]],
[CompareGreaterThanFunction::class, [CountAggregateFunction::class, 'tags->id'], 1],
]);
Assert::same(2, $books->count());

// Select books that:
// - have tags 2 OR 3
// - OR have more than 1 tags
// Matches books #1, #2, #3
$books = $this->orm->books->findBy([
ICollection::OR,
['tags->id' => [2, 3]],
[CompareGreaterThanFunction::class, [CountAggregateFunction::class, 'tags->id'], 1],
]);
Assert::same(3, $books->count());
}


public function testAnyDependent(): void
{
/*
Expand Down
Loading
Loading