From ef7fb8bb45ac64c5da42b8d53e90bc0c4ba2dc22 Mon Sep 17 00:00:00 2001 From: Jan Skrasek Date: Fri, 12 Jun 2026 22:39:50 +0200 Subject: [PATCH 1/2] fix for latest nette-di type tightening --- src/Bridges/NetteDI/OrmExtension.php | 1 + src/Bridges/NetteDI/PhpDocRepositoryFinder.php | 3 +++ .../integration/BridgeNetteDI/dic-extension-multiple.phpt | 3 ++- tests/cases/integration/BridgeNetteDI/dic-extension-order.phpt | 3 ++- tests/cases/integration/BridgeNetteDI/dic-finder.phpt | 3 ++- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Bridges/NetteDI/OrmExtension.php b/src/Bridges/NetteDI/OrmExtension.php index 095b396c6..6227f4772 100644 --- a/src/Bridges/NetteDI/OrmExtension.php +++ b/src/Bridges/NetteDI/OrmExtension.php @@ -177,6 +177,7 @@ protected function setupMetadataStorage(array $entityClassMap): void /** + * @param class-string $modelClass * @param array{ * array>, true>, * array>>, diff --git a/src/Bridges/NetteDI/PhpDocRepositoryFinder.php b/src/Bridges/NetteDI/PhpDocRepositoryFinder.php index 54c3df9fb..dd1738aa3 100644 --- a/src/Bridges/NetteDI/PhpDocRepositoryFinder.php +++ b/src/Bridges/NetteDI/PhpDocRepositoryFinder.php @@ -117,6 +117,9 @@ protected function setupMapperService(string $repositoryName, string $repository } + /** + * @param class-string> $repositoryClass + */ protected function setupRepositoryService(string $repositoryName, string $repositoryClass): void { $serviceName = $this->extension->prefix('repositories.' . $repositoryName); diff --git a/tests/cases/integration/BridgeNetteDI/dic-extension-multiple.phpt b/tests/cases/integration/BridgeNetteDI/dic-extension-multiple.phpt index 9d661e60a..e130334ec 100644 --- a/tests/cases/integration/BridgeNetteDI/dic-extension-multiple.phpt +++ b/tests/cases/integration/BridgeNetteDI/dic-extension-multiple.phpt @@ -23,10 +23,11 @@ function buildDic(string $config): Container $cacheDir = TEMP_DIR . '/cache/bridge-nette-dic-extension-multiple'; $loader = new ContainerLoader($cacheDir); $key = __FILE__ . ':' . __LINE__ . ':' . $config; - $className = $loader->load(function (Compiler $compiler) use ($config, $cacheDir): void { + $className = $loader->load(function (Compiler $compiler) use ($config, $cacheDir): ?string { $compiler->addExtension('extensions', new ExtensionsExtension()); $compiler->addConfig(['parameters' => ['tempDir' => $cacheDir]]); $compiler->loadConfig($config); + return null; }, $key); /** @var Container $dic */ diff --git a/tests/cases/integration/BridgeNetteDI/dic-extension-order.phpt b/tests/cases/integration/BridgeNetteDI/dic-extension-order.phpt index 5672cfff3..f1dc5712c 100644 --- a/tests/cases/integration/BridgeNetteDI/dic-extension-order.phpt +++ b/tests/cases/integration/BridgeNetteDI/dic-extension-order.phpt @@ -19,10 +19,11 @@ function buildDic(string $config): Container $cacheDir = TEMP_DIR . '/cache/bridge-nette-dic-extension-order'; $loader = new ContainerLoader($cacheDir); $key = __FILE__ . ':' . __LINE__ . ':' . $config; - $className = $loader->load(function (Compiler $compiler) use ($config, $cacheDir): void { + $className = $loader->load(function (Compiler $compiler) use ($config, $cacheDir): ?string { $compiler->addExtension('extensions', new ExtensionsExtension()); $compiler->addConfig(['parameters' => ['tempDir' => $cacheDir]]); $compiler->loadConfig($config); + return null; }, $key); /** @var Container $dic */ diff --git a/tests/cases/integration/BridgeNetteDI/dic-finder.phpt b/tests/cases/integration/BridgeNetteDI/dic-finder.phpt index 5cdd4d45d..3b7de0976 100644 --- a/tests/cases/integration/BridgeNetteDI/dic-finder.phpt +++ b/tests/cases/integration/BridgeNetteDI/dic-finder.phpt @@ -20,10 +20,11 @@ function buildDic(string $config): Container $cacheDir = TEMP_DIR . '/cache/bridge-nette-di-dic-finder'; $loader = new ContainerLoader($cacheDir); $key = __FILE__ . ':' . __LINE__ . ':' . $config; - $className = $loader->load(function (Compiler $compiler) use ($config, $cacheDir): void { + $className = $loader->load(function (Compiler $compiler) use ($config, $cacheDir): ?string { $compiler->addExtension('extensions', new ExtensionsExtension()); $compiler->addConfig(['parameters' => ['tempDir' => $cacheDir]]); $compiler->loadConfig($config); + return null; }, $key); /** @var Container $dic */ From a21fd802524d793c35b01847ebda7a0b833267ea Mon Sep 17 00:00:00 2001 From: Jan Skrasek Date: Tue, 7 Apr 2026 21:41:43 +0200 Subject: [PATCH 2/2] wip --- composer.json | 2 +- src/Collection/DbalCollection.php | 223 ++++++++++-------- .../Functions/Result/DbalTableJoin.php | 12 +- .../Dbal/RelationshipMapperManyHasMany.php | 4 +- .../integration/Collection/collection.phpt | 110 +++++++++ .../unit/Collection/DbalTableJoinTest.phpt | 69 ++++++ ...testCountStoredDoesNotResetFetchCursor.sql | 2 + ...testGetQueryBuilderDoesNotLeakToClones.sql | 2 + ...tQueryBuilderReturnsMutableBaseBuilder.sql | 2 + ...pByForOrderingIsIndependentOfCallOrder.sql | 43 ++++ ...stResetOrderByDropsOrderingFromGroupBy.sql | 35 +++ 11 files changed, 399 insertions(+), 105 deletions(-) create mode 100644 tests/cases/unit/Collection/DbalTableJoinTest.phpt create mode 100644 tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredDoesNotResetFetchCursor.sql create mode 100644 tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderDoesNotLeakToClones.sql create mode 100644 tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderReturnsMutableBaseBuilder.sql create mode 100644 tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGroupByForOrderingIsIndependentOfCallOrder.sql create mode 100644 tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testResetOrderByDropsOrderingFromGroupBy.sql diff --git a/composer.json b/composer.json index 781cf262a..a8fcdda55 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/src/Collection/DbalCollection.php b/src/Collection/DbalCollection.php index 1244a27d7..38fb333b6 100644 --- a/src/Collection/DbalCollection.php +++ b/src/Collection/DbalCollection.php @@ -38,14 +38,14 @@ class DbalCollection implements ICollection /** @var Iterator|null */ protected Iterator|null $fetchIterator = null; - /** @var array FindBy expressions for deferred processing */ - protected array $filtering = []; + /** @var array GROUP BY columns contributed by findBy() filtering. */ + private array $filterGroupByColumns = []; - /** @var array OrderBy expression result & sorting direction */ - protected array $ordering = []; + /** @var array GROUP BY columns contributed by orderBy() expressions. */ + private array $orderGroupByColumns = []; - /** @var array{int, int|null}|null */ - protected array|null $limitBy = null; + /** @var array Columns referenced by orderBy() expressions; required in GROUP BY once grouping is active. */ + private array $orderColumns = []; protected DbalQueryBuilderHelper|null $helper = null; @@ -54,7 +54,8 @@ class DbalCollection implements ICollection protected ?int $resultCount = null; protected bool $entityFetchEventTriggered = false; - protected QueryBuilder|null $queryBuilderCache = null; + /** @var array */ + private array $mysqlGroupByWorkaroundApplied = []; /** @@ -100,7 +101,25 @@ 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->filterGroupByColumns = $collection->mergeColumns($collection->filterGroupByColumns, $expression->groupBy); + $collection->rebuildGroupBy(); return $collection; } @@ -108,22 +127,15 @@ public function findBy(array $conds): ICollection public function orderBy($expression, string $direction = ICollection::ASC): ICollection { $collection = clone $this; - $helper = $collection->getHelper(); if (is_array($expression) && !isset($expression[0])) { /** @var array $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; } @@ -132,10 +144,10 @@ 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->orderGroupByColumns = []; + $collection->orderColumns = []; $collection->queryBuilder->orderBy(null); + $collection->rebuildGroupBy(); return $collection; } @@ -143,7 +155,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; } @@ -281,7 +293,7 @@ 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; @@ -290,76 +302,19 @@ public function __clone() /** - * @internal + * Returns the live, mutable query builder backing this collection. + * + * Callers may further mutate the returned builder, therefore any already fetched result is discarded so that the + * next fetch reflects the changes. Internal callers intentionally read {@see $queryBuilder} directly to avoid + * invalidating an in-progress iteration. */ 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; } @@ -369,7 +324,7 @@ protected function getIteratorCount(): int return $this->resultCount; } - $builder = clone $this->getQueryBuilder(); + $builder = clone $this->queryBuilder; if ($this->connection->getPlatform()->getName() === SqlServerPlatform::NAME) { if (!$builder->hasLimitOffsetClause()) { @@ -397,7 +352,7 @@ protected function getIteratorCount(): int protected function execute(): void { - $result = $this->connection->queryByQueryBuilder($this->getQueryBuilder()); + $result = $this->connection->queryByQueryBuilder($this->queryBuilder); $this->result = []; while (($data = $result->fetch()) !== null) { @@ -419,6 +374,75 @@ protected function getHelper(): DbalQueryBuilderHelper } + /** + * @param array|string $expression + */ + private function addOrderByExpression(string|array $expression, string $direction): void + { + $expressionResult = $this->getHelper()->processExpression($this->queryBuilder, $expression, null); + $this->applyExpressionJoins($expressionResult); + + $this->orderGroupByColumns = $this->mergeColumns($this->orderGroupByColumns, $expressionResult->groupBy); + $this->orderColumns = $this->mergeColumns($this->orderColumns, $expressionResult->columns); + $this->rebuildGroupBy(); + + $orderByExpression = $this->getHelper()->processOrderDirection($expressionResult, $direction); + $this->queryBuilder->addOrderBy('%ex', $orderByExpression); + } + + + /** + * Merges the given columns into a keyed map (deduplicated by their unescaped Fqn), preserving order. + * + * @param array $target + * @param list $columns + * @return array + */ + private function mergeColumns(array $target, array $columns): array + { + foreach ($columns as $fqn) { + $target[$fqn->getUnescaped()] ??= $fqn; + } + return $target; + } + + + /** + * Rebuilds the whole GROUP BY clause from the tracked filtering and ordering columns. + * + * The clause is recomputed instead of mutated incrementally so that it does not depend on the order in which + * findBy()/orderBy() were called, and so that resetOrderBy() can drop the ordering-induced columns again. + * Order-by columns are added only when grouping is otherwise active, matching the SQL grouping requirements. + */ + private function rebuildGroupBy(): void + { + $groupBy = $this->filterGroupByColumns + $this->orderGroupByColumns; + if (count($groupBy) > 0) { + $groupBy += $this->orderColumns; + } + + if (count($groupBy) === 0) { + $this->queryBuilder->groupBy(null); + return; + } + + $this->queryBuilder->groupBy('%column[]', array_values($groupBy)); + + if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) { + $this->applyGroupByWithSameNamedColumnsWorkaround($this->queryBuilder, array_values($groupBy)); + } + } + + + 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 @@ -430,17 +454,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; } } } diff --git a/src/Collection/Functions/Result/DbalTableJoin.php b/src/Collection/Functions/Result/DbalTableJoin.php index 416cb6bba..e401c4b08 100644 --- a/src/Collection/Functions/Result/DbalTableJoin.php +++ b/src/Collection/Functions/Result/DbalTableJoin.php @@ -5,6 +5,8 @@ use Nextras\Dbal\Platforms\Data\Fqn; use Nextras\Dbal\QueryBuilder\QueryBuilder; +use function md5; +use function serialize; /** @@ -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])), ); } } diff --git a/src/Mapper/Dbal/RelationshipMapperManyHasMany.php b/src/Mapper/Dbal/RelationshipMapperManyHasMany.php index d66e8aa3f..cbd892188 100644 --- a/src/Mapper/Dbal/RelationshipMapperManyHasMany.php +++ b/src/Mapper/Dbal/RelationshipMapperManyHasMany.php @@ -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 @@ -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 diff --git a/tests/cases/integration/Collection/collection.phpt b/tests/cases/integration/Collection/collection.phpt index cdb3e6638..598c33743 100644 --- a/tests/cases/integration/Collection/collection.phpt +++ b/tests/cases/integration/Collection/collection.phpt @@ -11,6 +11,8 @@ namespace NextrasTests\Orm\Integration\Collection; use Nextras\Orm\Collection\ArrayCollection; use Nextras\Orm\Collection\DbalCollection; use Nextras\Orm\Collection\EmptyCollection; +use Nextras\Orm\Collection\Functions\CompareGreaterThanFunction; +use Nextras\Orm\Collection\Functions\CountAggregateFunction; use Nextras\Orm\Collection\HasManyCollection; use Nextras\Orm\Collection\ICollection; use Nextras\Orm\Exception\NoResultException; @@ -394,6 +396,114 @@ 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 testCountStoredDoesNotResetFetchCursor(): void + { + if ($this->section === Helper::SECTION_ARRAY) { + Environment::skip('DbalCollection only.'); + } + + $books = $this->orm->books->findAll()->orderBy('id'); + + $first = $books->fetch(); + Assert::notNull($first); + Assert::same(1, $first->id); + + // counting the stored rows must not disturb the already-opened fetch cursor + Assert::same(4, $books->countStored()); + + $second = $books->fetch(); + Assert::notNull($second); + Assert::same(2, $second->id); + } + + + public function testGroupByForOrderingIsIndependentOfCallOrder(): void + { + if ($this->section === Helper::SECTION_ARRAY) { + Environment::skip('DbalCollection only.'); + } + + // grouping (introduced by the aggregation filter) is added before the order-by + $filterFirst = $this->orm->books->findAll() + ->findBy([CompareGreaterThanFunction::class, [CountAggregateFunction::class, 'tags->id'], 0]) + ->orderBy('author->name') + ->orderBy('id') + ->fetchPairs(null, 'id'); + + // the very same query, but the order-by is added before the grouping aggregation; + // the joined order-by column must still end up in the GROUP BY clause + $orderFirst = $this->orm->books->findAll() + ->orderBy('author->name') + ->orderBy('id') + ->findBy([CompareGreaterThanFunction::class, [CountAggregateFunction::class, 'tags->id'], 0]) + ->fetchPairs(null, 'id'); + + Assert::same($filterFirst, $orderFirst); + } + + + public function testResetOrderByDropsOrderingFromGroupBy(): void + { + if ($this->section === Helper::SECTION_ARRAY) { + Environment::skip('DbalCollection only.'); + } + + // baseline: a grouped query (count over to-many tags) without any ordering + $expected = $this->orm->books->findAll() + ->findBy([CompareGreaterThanFunction::class, [CountAggregateFunction::class, 'tags->id'], 0]) + ->fetchPairs(null, 'id'); + + // the same query, ordered by a to-many relation column which forces tags->name + // into the GROUP BY clause, and then reset + $actual = $this->orm->books->findAll() + ->findBy([CompareGreaterThanFunction::class, [CountAggregateFunction::class, 'tags->id'], 0]) + ->orderBy('tags->name') + ->resetOrderBy() + ->fetchPairs(null, 'id'); + + // resetting the ordering must also drop the ordering-induced GROUP BY columns, + // otherwise books with multiple tags are returned multiple times + sort($expected); + sort($actual); + Assert::same($expected, $actual); + } + + public function testPrefixCollectionFunction(): void { $author = $this->orm->books->findBy([TestingPrefixFunction::class, 'author->name', 'Wri']); diff --git a/tests/cases/unit/Collection/DbalTableJoinTest.phpt b/tests/cases/unit/Collection/DbalTableJoinTest.phpt new file mode 100644 index 000000000..2aa6c3e2a --- /dev/null +++ b/tests/cases/unit/Collection/DbalTableJoinTest.phpt @@ -0,0 +1,69 @@ +from('book', 'b')->select('*'); + + $joinA = new DbalTableJoin( + toExpression: '%table', + toArgs: ['book_tag'], + toAlias: 'tag', + onExpression: '%table.%column = %table.%column', + onArgs: ['b', 'tag_id', 'tag', 'id'], + ); + $joinB = new DbalTableJoin( + toExpression: '%table', + toArgs: ['author_tag'], + toAlias: 'tag', + onExpression: '%table.%column = %table.%column', + onArgs: ['b', 'tag_id', 'tag', 'id'], + ); + + $joinA->applyJoin($builder); + $joinB->applyJoin($builder); + + Assert::same( + [ + 'SELECT * FROM book AS [b] ' + . 'LEFT JOIN %table AS [tag] ON (%table.%column = %table.%column) ' + . 'LEFT JOIN %table AS [tag] ON (%table.%column = %table.%column)', + 'book_tag', + 'b', + 'tag_id', + 'tag', + 'id', + 'author_tag', + 'b', + 'tag_id', + 'tag', + 'id', + ], + [$builder->getQuerySql(), ...$builder->getQueryParameters()], + ); + } +} + + +$test = new DbalTableJoinTest(); +$test->run(); diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredDoesNotResetFetchCursor.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredDoesNotResetFetchCursor.sql new file mode 100644 index 000000000..52f16bed2 --- /dev/null +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testCountStoredDoesNotResetFetchCursor.sql @@ -0,0 +1,2 @@ +SELECT "books".* FROM "books" AS "books" ORDER BY "books"."id" ASC; +SELECT COUNT(*) AS count FROM (SELECT "books"."id" FROM "books" AS "books") temp; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderDoesNotLeakToClones.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderDoesNotLeakToClones.sql new file mode 100644 index 000000000..c45595e3b --- /dev/null +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderDoesNotLeakToClones.sql @@ -0,0 +1,2 @@ +SELECT "books".* FROM "books" AS "books" WHERE ("books"."id" IN (1, 2, 3, 4)) AND ("books"."id" = 1) ORDER BY "books"."id" ASC; +SELECT "books".* FROM "books" AS "books" WHERE "books"."id" IN (1, 2, 3, 4) ORDER BY "books"."id" ASC; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderReturnsMutableBaseBuilder.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderReturnsMutableBaseBuilder.sql new file mode 100644 index 000000000..52f469715 --- /dev/null +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGetQueryBuilderReturnsMutableBaseBuilder.sql @@ -0,0 +1,2 @@ +SELECT "books".* FROM "books" AS "books" WHERE "books"."id" IN (1, 2, 3) ORDER BY "books"."id" ASC; +SELECT "books".* FROM "books" AS "books" WHERE ("books"."id" IN (1, 2, 3)) AND ("books"."id" IN (2, 3, 4)) ORDER BY "books"."id" ASC; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGroupByForOrderingIsIndependentOfCallOrder.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGroupByForOrderingIsIndependentOfCallOrder.sql new file mode 100644 index 000000000..d9ed3f9b2 --- /dev/null +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testGroupByForOrderingIsIndependentOfCallOrder.sql @@ -0,0 +1,43 @@ +SELECT + "books".* +FROM + "books" AS "books" + LEFT JOIN "books_x_tags" AS "books_x_tags__COUNT" ON ( + "books"."id" = "books_x_tags__COUNT"."book_id" + ) + LEFT JOIN "tags" AS "tags__COUNT" ON ( + "books_x_tags__COUNT"."tag_id" = "tags__COUNT"."id" + ) + LEFT JOIN "public"."authors" AS "author" ON ( + "books"."author_id" = "author"."id" + ) +GROUP BY + "books"."id", + "author"."name" +HAVING + COUNT("tags__COUNT"."id") > 0 +ORDER BY + "author"."name" ASC, + "books"."id" ASC; + +SELECT + "books".* +FROM + "books" AS "books" + LEFT JOIN "public"."authors" AS "author" ON ( + "books"."author_id" = "author"."id" + ) + LEFT JOIN "books_x_tags" AS "books_x_tags__COUNT" ON ( + "books"."id" = "books_x_tags__COUNT"."book_id" + ) + LEFT JOIN "tags" AS "tags__COUNT" ON ( + "books_x_tags__COUNT"."tag_id" = "tags__COUNT"."id" + ) +GROUP BY + "books"."id", + "author"."name" +HAVING + COUNT("tags__COUNT"."id") > 0 +ORDER BY + "author"."name" ASC, + "books"."id" ASC; diff --git a/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testResetOrderByDropsOrderingFromGroupBy.sql b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testResetOrderByDropsOrderingFromGroupBy.sql new file mode 100644 index 000000000..cc1521762 --- /dev/null +++ b/tests/sqls/NextrasTests/Orm/Integration/Collection/CollectionTest_testResetOrderByDropsOrderingFromGroupBy.sql @@ -0,0 +1,35 @@ +SELECT + "books".* +FROM + "books" AS "books" + LEFT JOIN "books_x_tags" AS "books_x_tags__COUNT" ON ( + "books"."id" = "books_x_tags__COUNT"."book_id" + ) + LEFT JOIN "tags" AS "tags__COUNT" ON ( + "books_x_tags__COUNT"."tag_id" = "tags__COUNT"."id" + ) +GROUP BY + "books"."id" +HAVING + COUNT("tags__COUNT"."id") > 0; + +SELECT + "books".* +FROM + "books" AS "books" + LEFT JOIN "books_x_tags" AS "books_x_tags__COUNT" ON ( + "books"."id" = "books_x_tags__COUNT"."book_id" + ) + LEFT JOIN "tags" AS "tags__COUNT" ON ( + "books_x_tags__COUNT"."tag_id" = "tags__COUNT"."id" + ) + LEFT JOIN "books_x_tags" AS "books_x_tags_any" ON ( + "books"."id" = "books_x_tags_any"."book_id" + ) + LEFT JOIN "tags" AS "tags_any" ON ( + "books_x_tags_any"."tag_id" = "tags_any"."id" + ) +GROUP BY + "books"."id" +HAVING + COUNT("tags__COUNT"."id") > 0;