From df8e95b926ad5a2b3b16210a445b46c05a22adcf Mon Sep 17 00:00:00 2001 From: bancer Date: Sun, 23 Nov 2025 17:01:04 +0100 Subject: [PATCH] Improve mapping of belongsToMany association --- src/ORM/AutoHydratorRecursive.php | 209 ++++++++++++------- tests/TestCase/ORM/NativeQueryMapperTest.php | 58 ++++- 2 files changed, 188 insertions(+), 79 deletions(-) diff --git a/src/ORM/AutoHydratorRecursive.php b/src/ORM/AutoHydratorRecursive.php index 2c095b6..ffecaf4 100644 --- a/src/ORM/AutoHydratorRecursive.php +++ b/src/ORM/AutoHydratorRecursive.php @@ -15,14 +15,28 @@ class AutoHydratorRecursive { + /** + * A list of uknown aliases. + * + * @var string[] + */ + private array $unknownAliases = []; + protected Table $rootTable; - /** @var array */ + /** @var array SQL alias => fields */ protected array $aliasMap = []; - /** @var array */ + /** @var array SQL alias => Table instance */ protected array $tableByAlias = []; + /** + * Precomputed mapping strategy. + * + * @var array + */ + protected array $mappingStrategy = []; + /** * @param Table $rootTable * @param mixed[] $rows @@ -36,7 +50,9 @@ public function __construct(Table $rootTable, array $rows) } $keys = array_keys($first); $this->aliasMap = $this->buildAliasMapFromRowKeys($keys); - $this->validateAndResolveAliases(); + $allAliases = array_keys($this->aliasMap); + $this->unknownAliases = array_combine($allAliases, $allAliases); + $this->buildMappingStrategy(); } /** @@ -59,15 +75,19 @@ protected function buildAliasMapFromRowKeys(array $keys): array return $map; } - protected function validateAndResolveAliases(): void + /** + * Precompute mapping strategy and resolve table aliases. + */ + protected function buildMappingStrategy(): void { $rootAlias = $this->rootTable->getAlias(); $this->tableByAlias[$rootAlias] = $this->rootTable; + $this->mappingStrategy = []; foreach ($this->aliasMap as $alias => $_fields) { if ($alias === $rootAlias) { continue; } - $table = $this->resolveTableByAlias($alias); + $table = $this->resolveTableByAliasRecursive($alias); if ($table === null) { throw new UnknownAliasException( "SQL alias '$alias' does not match any reachable Table from '$rootAlias'." @@ -75,6 +95,54 @@ protected function validateAndResolveAliases(): void } $this->tableByAlias[$alias] = $table; } + $allAliases = array_keys($this->aliasMap); + $aliasesToMap = array_combine($allAliases, $allAliases); + foreach ($this->tableByAlias as $alias => $table) { + if (isset($aliasesToMap[$alias])) { + $this->mappingStrategy[$alias] = []; + foreach ($table->associations() as $assoc) { + $type = null; + if ($assoc instanceof HasOne) { + $type = 'hasOne'; + } elseif ($assoc instanceof BelongsTo) { + $type = 'belongsTo'; + } elseif ($assoc instanceof BelongsToMany) { + $type = 'belongsToMany'; + } elseif ($assoc instanceof HasMany) { + $type = 'hasMany'; + } + if ($type === null) { + continue; + } + $childAlias = $assoc->getTarget()->getAlias(); + if (!isset($aliasesToMap[$childAlias])) { + continue; + } + $entry = []; + if ($assoc instanceof BelongsToMany) { + $through = $assoc->getThrough(); + if ($through === null) { + $through = $assoc->junction(); + } + if (is_object($through)) { + $through = $through->getAlias(); + } + $entry['through'] = $through; + if (isset($aliasesToMap[$through])) { + unset($aliasesToMap[$through]); + } + } + $entry['property'] = $assoc->getProperty(); + $this->mappingStrategy[$alias][$type][$childAlias] = $entry; + unset($aliasesToMap[$childAlias]); + } + } + } + } + + protected function resolveTableByAlias(string $alias): ?Table + { + return $this->tableByAlias[$alias] ?? null; } /** @@ -92,37 +160,40 @@ protected function validateAndResolveAliases(): void * @param string $alias The SQL alias to resolve. * @return \Cake\ORM\Table|null The Table instance corresponding to the alias, or null if not found. */ - protected function resolveTableByAlias(string $alias): ?Table + protected function resolveTableByAliasRecursive(string $alias): ?Table { - if (isset($this->tableByAlias[$alias])) { - return $this->tableByAlias[$alias]; - } $visited = []; $queue = [$this->rootTable]; - while ($queue) { + while ($queue && !empty($this->unknownAliases)) { /** @var Table $table */ $table = array_shift($queue); $visited[$table->getAlias()] = true; foreach ($table->associations() as $assoc) { $target = $assoc->getTarget(); $ta = $target->getAlias(); - if ($ta === $alias) { - return $target; + if (isset($this->unknownAliases[$ta])) { + unset($this->unknownAliases[$ta]); + if ($ta === $alias) { + return $target; + } } if (!isset($visited[$ta])) { $queue[] = $target; } if ($assoc instanceof BelongsToMany) { - $junctionAlias = $assoc->getThrough(); - if ($junctionAlias) { - if (is_object($junctionAlias)) { - $junctionAlias = $junctionAlias->getAlias(); + $through = $assoc->getThrough(); + if ($through !== null) { + if (is_object($through)) { + $through = $through->getAlias(); } - if ($junctionAlias === $alias) { - return TableRegistry::getTableLocator()->get($junctionAlias); + if (isset($this->unknownAliases[$through])) { + unset($this->unknownAliases[$through]); + if ($through === $alias) { + return TableRegistry::getTableLocator()->get($through); + } } - if (!isset($visited[$junctionAlias])) { - $queue[] = TableRegistry::getTableLocator()->get($junctionAlias); + if (!isset($visited[$through])) { + $queue[] = TableRegistry::getTableLocator()->get($through); } } } @@ -187,71 +258,53 @@ protected function buildEntityRecursive( ] ); $out[$alias] = $entity; - foreach ($table->associations() as $assoc) { - $target = $assoc->getTarget(); - $childAlias = $target->getAlias(); - if ( - !isset($this->aliasMap[$childAlias]) && - !( - $assoc instanceof BelongsToMany && - isset($this->aliasMap[$assoc->junction()->getAlias()]) - ) - ) { - continue; - } - if ($assoc instanceof HasMany) { - $tree = $this->buildEntityRecursive($target, $row, $visited); - if ($tree) { - $list = $entity->get($assoc->getProperty()); - if (!is_array($list)) { - $list = []; + foreach ($this->mappingStrategy[$alias] ?? [] as $type => $children) { + if (is_array($children)) { + foreach ($children as $childAlias => $assocData) { + if (!isset($this->aliasMap[$childAlias])) { + continue; } - $list[] = $tree[$childAlias]; - $entity->set($assoc->getProperty(), $list); - $out += $tree; - } - continue; - } - if ($assoc instanceof BelongsTo || $assoc instanceof HasOne) { - $tree = $this->buildEntityRecursive($target, $row, $visited); - if ($tree) { - $entity->set($assoc->getProperty(), $tree[$childAlias]); - $out += $tree; - } - continue; - } - if ($assoc instanceof BelongsToMany) { - $tree = $this->buildEntityRecursive($target, $row, $visited); - if ($tree) { - $child = $tree[$childAlias]; - $junctionAlias = $assoc->getThrough(); - if (is_object($junctionAlias)) { - $junctionAlias = $junctionAlias->getAlias(); + $childTable = $this->tableByAlias[$childAlias]; + $tree = $this->buildEntityRecursive($childTable, $row, $visited); + if (!$tree) { + continue; } - // hydrate join data only if the row contains it - if ($junctionAlias !== null && isset($this->aliasMap[$junctionAlias])) { - $junctionTable = TableRegistry::getTableLocator()->get($junctionAlias); - $jTree = $this->buildEntityRecursive($junctionTable, $row, $visited); - if ($jTree) { - $child->set('_joinData', $jTree[$junctionAlias]); - $out += $jTree; + $childEntity = $tree[$childAlias]; + if ($type === 'belongsToMany') { + $throughAlias = null; + if (is_array($assocData) && isset($assocData['through'])) { + $throughAlias = $assocData['through']; + } + if (is_string($throughAlias) && isset($this->aliasMap[$throughAlias])) { + $throughTable = $this->tableByAlias[$throughAlias]; + $jTree = $this->buildEntityRecursive($throughTable, $row, $visited); + if ($jTree) { + $childEntity->set('_joinData', [$jTree[$throughAlias]]); + $out += $jTree; + } } } - $list = $entity->get($assoc->getProperty()); - if (!is_array($list)) { - $list = []; + $prop = null; + if (is_array($assocData) && isset($assocData['property'])) { + $prop = $assocData['property']; + } + if ($type === 'hasMany' || $type === 'belongsToMany') { + if (!is_string($prop)) { + $prop = $childAlias; + } + $list = $entity->get($prop); + if (!is_array($list)) { + $list = []; + } + $list[] = $childEntity; + $entity->set($prop, $list); + } else { + if (is_string($prop)) { + $entity->set($prop, $childEntity); + } } - $list[] = $child; - $entity->set($assoc->getProperty(), $list); $out += $tree; } - continue; - } - // fallback - $tree = $this->buildEntityRecursive($target, $row, $visited); - if ($tree) { - $entity->set($assoc->getProperty(), $tree[$childAlias]); - $out += $tree; } } unset($visited[$alias]); diff --git a/tests/TestCase/ORM/NativeQueryMapperTest.php b/tests/TestCase/ORM/NativeQueryMapperTest.php index f7b0e22..467a33a 100644 --- a/tests/TestCase/ORM/NativeQueryMapperTest.php +++ b/tests/TestCase/ORM/NativeQueryMapperTest.php @@ -178,7 +178,6 @@ public function testBelongsToMany(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); - //print_r($ArticlesTable->find()->contain(['Tags'])->enableHydration(false)->toArray()); $stmt = $ArticlesTable->prepareSQL(" SELECT Articles.id AS Articles__id, @@ -214,4 +213,61 @@ public function testBelongsToMany(): void ]; static::assertSame($expected, $actual[0]->toArray()); } + + public function testBelongsToManyFetchJoinTable(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + Articles.id AS Articles__id, + Articles.title AS Articles__title, + Tags.id AS Tags__id, + Tags.name AS Tags__name, + ArticlesTags.id AS ArticlesTags__id, + ArticlesTags.article_id AS ArticlesTags__article_id, + ArticlesTags.tag_id AS ArticlesTags__tag_id + FROM articles AS Articles + LEFT JOIN articles_tags AS ArticlesTags + ON Articles.id=ArticlesTags.article_id + LEFT JOIN tags AS Tags + ON Tags.id=ArticlesTags.tag_id + "); + $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Article::class, $actual[0]); + $actualTags = $actual[0]->get('tags'); + static::assertIsArray($actualTags); + static::assertCount(2, $actualTags); + static::assertInstanceOf(Tag::class, $actualTags[0]); + $expected = [ + 'id' => 1, + 'title' => 'Article 1', + 'tags' => [ + [ + 'id' => 1, + 'name' => 'Tech', + '_joinData' => [ + [ + 'id' => 1, + 'article_id' => 1, + 'tag_id' => 1, + ], + ], + ], + [ + 'id' => 2, + 'name' => 'Food', + '_joinData' => [ + [ + 'id' => 2, + 'article_id' => 1, + 'tag_id' => 2, + ], + ], + ], + ], + ]; + static::assertSame($expected, $actual[0]->toArray()); + } }