Skip to content
Open
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"ext-ctype": "*",
"nette/caching": "~3.2 || ~3.1.3",
"nette/utils": "~3.0 || ~4.0",
"nextras/dbal": "^5.0.3",
"nextras/dbal": "dev-js/duplicated-joins",
"phpstan/phpdoc-parser": "^1.33.0 || ^2.0.0"
},
"require-dev": {
Expand Down
205 changes: 104 additions & 101 deletions src/Collection/DbalCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,8 @@ class DbalCollection implements ICollection
/** @var Iterator<E>|null */
protected Iterator|null $fetchIterator = null;

/** @var array<mixed> FindBy expressions for deferred processing */
protected array $filtering = [];

/** @var array<array{DbalExpressionResult, string}> OrderBy expression result & sorting direction */
protected array $ordering = [];

/** @var array{int, int|null}|null */
protected array|null $limitBy = null;
/** @var array<string, Fqn> */
protected array $groupByColumns = [];

protected DbalQueryBuilderHelper|null $helper = null;

Expand All @@ -54,7 +48,8 @@ class DbalCollection implements ICollection
protected ?int $resultCount = null;
protected bool $entityFetchEventTriggered = false;

protected QueryBuilder|null $queryBuilderCache = null;
/** @var array<string, true> */
private array $mysqlGroupByWorkaroundApplied = [];


/**
Expand Down Expand Up @@ -100,30 +95,40 @@ public function getByIdChecked($id): IEntity
public function findBy(array $conds): ICollection
{
$collection = clone $this;
$collection->filtering[] = $conds;
$expression = $collection->getHelper()->processExpression(
builder: $collection->queryBuilder,
expression: $conds,
aggregator: null,
);
$finalContext = $expression->havingExpression === null
? ExpressionContext::FilterAnd
: ExpressionContext::FilterAndWithHavingClause;
$expression = $expression->collect($finalContext);

$collection->applyExpressionJoins($expression);
if ($expression->expression !== null && $expression->args !== []) {
$collection->queryBuilder->andWhere($expression->expression, ...$expression->args);
}
if ($expression->havingExpression !== null && $expression->havingArgs !== []) {
$collection->queryBuilder->andHaving($expression->havingExpression, ...$expression->havingArgs);
}
$collection->applyGroupByColumns($expression->groupBy);
return $collection;
}


public function orderBy($expression, string $direction = ICollection::ASC): ICollection
{
$collection = clone $this;
$helper = $collection->getHelper();
if (is_array($expression) && !isset($expression[0])) {
/** @var array<string, string> $expression */
$expression = $expression; // no-op for PHPStan

foreach ($expression as $subExpression => $subDirection) {
$collection->ordering[] = [
$helper->processExpression($collection->queryBuilder, $subExpression, null),
$subDirection,
];
$collection->addOrderByExpression($subExpression, $subDirection);
}
} else {
$collection->ordering[] = [
$helper->processExpression($collection->queryBuilder, $expression, null),
$direction,
];
$collection->addOrderByExpression($expression, $direction);
}
return $collection;
}
Expand All @@ -132,9 +137,6 @@ public function orderBy($expression, string $direction = ICollection::ASC): ICol
public function resetOrderBy(): ICollection
{
$collection = clone $this;
$collection->ordering = [];
// reset default ordering from mapper
$collection->queryBuilder = clone $collection->queryBuilder;
$collection->queryBuilder->orderBy(null);
return $collection;
}
Expand All @@ -143,7 +145,7 @@ public function resetOrderBy(): ICollection
public function limitBy(int $limit, int|null $offset = null): ICollection
{
$collection = clone $this;
$collection->limitBy = [$limit, $offset];
$collection->queryBuilder->limitBy($limit, $offset);
return $collection;
}

Expand Down Expand Up @@ -281,85 +283,21 @@ public function subscribeOnEntityFetch(callable $callback): void

public function __clone()
{
$this->queryBuilderCache = null;
$this->queryBuilder = clone $this->queryBuilder;
$this->result = null;
$this->resultCount = null;
$this->fetchIterator = null;
$this->entityFetchEventTriggered = false;
}


/**
* @internal
*/
public function getQueryBuilder(): QueryBuilder
{
if ($this->queryBuilderCache !== null) {
return $this->queryBuilderCache;
}

$joins = [];
$groupBy = [];
$queryBuilder = clone $this->queryBuilder;
$helper = $this->getHelper();

$filtering = $this->filtering;
if (count($filtering) > 0) {
array_unshift($filtering, ICollection::AND);
$expression = $helper->processExpression(
builder: $queryBuilder,
expression: $filtering,
aggregator: null,
);
$finalContext = $expression->havingExpression === null
? ExpressionContext::FilterAnd
: ExpressionContext::FilterAndWithHavingClause;
$expression = $expression->collect($finalContext);
$joins = $expression->joins;
$groupBy = $expression->groupBy;
if ($expression->expression !== null && $expression->args !== []) {
$queryBuilder->andWhere($expression->expression, ...$expression->args);
}
if ($expression->havingExpression !== null && $expression->havingArgs !== []) {
$queryBuilder->andHaving($expression->havingExpression, ...$expression->havingArgs);
}
if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) {
$this->applyGroupByWithSameNamedColumnsWorkaround($queryBuilder, $groupBy);
}
}

foreach ($this->ordering as [$expression, $direction]) {
$joins = array_merge($joins, $expression->joins);
$groupBy = array_merge($groupBy, $expression->groupBy);
$orderingExpression = $helper->processOrderDirection($expression, $direction);
$queryBuilder->addOrderBy('%ex', $orderingExpression);
}

if ($this->limitBy !== null) {
$queryBuilder->limitBy($this->limitBy[0], $this->limitBy[1]);
}

$mergedJoins = $helper->mergeJoins('%and', $joins);
foreach ($mergedJoins as $join) {
$join->applyJoin($queryBuilder);
}

if (count($groupBy) > 0) {
foreach ($this->ordering as [$expression]) {
$groupBy = array_merge($groupBy, $expression->columns);
}
}

if (count($groupBy) > 0) {
$unique = [];
foreach ($groupBy as $groupByFqn) {
$unique[$groupByFqn->getUnescaped()] = $groupByFqn;
}
$queryBuilder->groupBy('%column[]', array_values($unique));
}

$this->queryBuilderCache = $queryBuilder;
return $queryBuilder;
$this->result = null;
$this->resultCount = null;
$this->fetchIterator = null;
$this->entityFetchEventTriggered = false;
return $this->queryBuilder;
}


Expand Down Expand Up @@ -419,6 +357,70 @@ protected function getHelper(): DbalQueryBuilderHelper
}


/**
* @param list<Fqn> $groupBy
*/
private function applyGroupByColumns(array $groupBy): void
{
if (count($groupBy) === 0) {
return;
}

$newGroupBy = [];
foreach ($groupBy as $groupByFqn) {
$key = $groupByFqn->getUnescaped();
if (isset($this->groupByColumns[$key])) {
continue;
}

$this->groupByColumns[$key] = $groupByFqn;
$newGroupBy[] = $groupByFqn;
}

if (count($newGroupBy) === 0) {
return;
}

if ($this->queryBuilder->getClause('group')[0] === null) {
$this->queryBuilder->groupBy('%column[]', $newGroupBy);
} else {
foreach ($newGroupBy as $groupByFqn) {
$this->queryBuilder->addGroupBy('%column', $groupByFqn);
}
}

if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) {
$this->applyGroupByWithSameNamedColumnsWorkaround($this->queryBuilder, array_values($this->groupByColumns));
}
}

/**
* @param array<mixed>|string $expression
*/
private function addOrderByExpression(string|array $expression, string $direction): void
{
$expressionResult = $this->getHelper()->processExpression($this->queryBuilder, $expression, null);
$this->applyExpressionJoins($expressionResult);
$this->applyGroupByColumns($expressionResult->groupBy);

$orderByExpression = $this->getHelper()->processOrderDirection($expressionResult, $direction);
$this->queryBuilder->addOrderBy('%ex', $orderByExpression);

if ($this->queryBuilder->getClause('group')[0] !== null) {
$this->applyGroupByColumns($expressionResult->columns);
}
}


private function applyExpressionJoins(DbalExpressionResult $expression): void
{
$mergedJoins = $this->getHelper()->mergeJoins('%and', $expression->joins);
foreach ($mergedJoins as $join) {
$join->applyJoin($this->queryBuilder);
}
}


/**
* Apply workaround for MySQL that is not able to properly resolve columns when there are more same-named
* columns in the GROUP BY clause, even though they are properly referenced to their tables. Orm workarounds
Expand All @@ -430,17 +432,18 @@ private function applyGroupByWithSameNamedColumnsWorkaround(QueryBuilder $queryB
{
$map = [];
foreach ($groupBy as $fqn) {
if (!isset($map[$fqn->name])) {
$map[$fqn->name] = [$fqn];
} else {
$map[$fqn->name][] = $fqn;
}
$map[$fqn->name][$fqn->getUnescaped()] = $fqn;
}
$i = 0;

foreach ($map as $fqns) {
if (count($fqns) > 1) {
foreach ($fqns as $fqn) {
$queryBuilder->addSelect("%column AS __nextras_fix_" . $i++, $fqn); // @phpstan-ignore-line
foreach ($fqns as $key => $fqn) {
if (isset($this->mysqlGroupByWorkaroundApplied[$key])) {
continue;
}

$queryBuilder->addSelect("%column AS __nextras_fix_" . count($this->mysqlGroupByWorkaroundApplied), $fqn); // @phpstan-ignore-line
$this->mysqlGroupByWorkaroundApplied[$key] = true;
}
}
}
Expand Down
12 changes: 9 additions & 3 deletions src/Collection/Functions/Result/DbalTableJoin.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

use Nextras\Dbal\Platforms\Data\Fqn;
use Nextras\Dbal\QueryBuilder\QueryBuilder;
use function md5;
use function serialize;


/**
Expand Down Expand Up @@ -41,11 +43,15 @@ public function __construct(

public function applyJoin(QueryBuilder $queryBuilder): void
{
$queryBuilder->joinLeft(
$queryBuilder->joinOnce(
'LEFT',
"$this->toExpression AS [$this->toAlias]",
$this->onExpression,
...$this->toArgs,
...$this->onArgs,
[
...$this->toArgs,
...$this->onArgs,
],
hashSuffix: md5(serialize([$this->toArgs, $this->onArgs])),
);
}
}
4 changes: 2 additions & 2 deletions src/Mapper/Dbal/RelationshipMapperManyHasMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ private function fetchByTwoPassStrategy(QueryBuilder $builder, array $values): M
$hasOrderBy = $builder->getClause('order')[0] !== null;

$builder = clone $builder;
$builder->joinLeft(
$builder->addLeftJoin(
"%table AS %table",
'%column = %column',
// args
Expand Down Expand Up @@ -232,7 +232,7 @@ private function fetchCounts(QueryBuilder $builder, array $values): array
$targetTable = DbalQueryBuilderHelper::getAlias($this->joinTable);

$builder = clone $builder;
$builder->joinLeft(
$builder->addLeftJoin(
'%table AS %table',
'%column = %column',
// args
Expand Down
35 changes: 35 additions & 0 deletions tests/cases/integration/Collection/collection.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,41 @@ class CollectionTest extends DataTestCase
}


public function testGetQueryBuilderReturnsMutableBaseBuilder(): void
{
if ($this->section === Helper::SECTION_ARRAY) {
Environment::skip('DbalCollection only.');
}

$books = $this->orm->books->findAll();
Assert::type(DbalCollection::class, $books);

$builder = $books->getQueryBuilder();
$builder->andWhere('%table.%column IN %any', $builder->getFromAlias(), 'id', [1, 2, 3]);

Assert::same([1, 2, 3], $books->orderBy('id')->fetchPairs(null, 'id'));
Assert::same([2, 3], $books->findBy(['id' => [2, 3, 4]])->orderBy('id')->fetchPairs(null, 'id'));
}


public function testGetQueryBuilderDoesNotLeakToClones(): void
{
if ($this->section === Helper::SECTION_ARRAY) {
Environment::skip('DbalCollection only.');
}

$books = $this->orm->books->findAll();
Assert::type(DbalCollection::class, $books);

$filtered = $books->findBy(['id' => [1, 2, 3, 4]]);
$builder = $filtered->getQueryBuilder();
$builder->andWhere('%table.%column = %any', $builder->getFromAlias(), 'id', 1);

Assert::same([1], $filtered->orderBy('id')->fetchPairs(null, 'id'));
Assert::same([1, 2, 3, 4], $books->findBy(['id' => [1, 2, 3, 4]])->orderBy('id')->fetchPairs(null, 'id'));
}


public function testPrefixCollectionFunction(): void
{
$author = $this->orm->books->findBy([TestingPrefixFunction::class, 'author->name', 'Wri']);
Expand Down
Loading