diff --git a/src/ORM/AutoHydratorRecursive.php b/src/ORM/AutoHydratorRecursive.php index 0b379b3..385bb9b 100644 --- a/src/ORM/AutoHydratorRecursive.php +++ b/src/ORM/AutoHydratorRecursive.php @@ -21,14 +21,6 @@ class AutoHydratorRecursive 'belongsToMany', ]; - /** - * @var string[] - */ - protected array $aliases; - - /** @var array SQL alias => fields */ - protected array $aliasMap = []; - /** * Precomputed mapping strategy. * @@ -54,40 +46,12 @@ class AutoHydratorRecursive /** * @param \Cake\ORM\Table $rootTable - * @param mixed[] $rows The result set of `$this->stmt->fetchAll(\PDO::FETCH_ASSOC);`. + * @param mixed[] $mappingStrategy Mapping strategy. */ - public function __construct(Table $rootTable, array $rows) + public function __construct(Table $rootTable, array $mappingStrategy) { $this->rootTable = $rootTable; - $firstRow = $rows[0] ?? []; - if (!is_array($firstRow)) { - throw new \InvalidArgumentException('First element of the result set is not an array'); - } - $keys = array_keys($firstRow); - $this->aliasMap = $this->buildAliasMap($keys); - $this->aliases = array_keys($this->aliasMap); - $strategy = new MappingStrategy($rootTable, $this->aliases); - $this->mappingStrategy = $strategy->build()->toArray(); - } - - /** - * @param (string|null)[] $keys - * @return string[][] - */ - protected function buildAliasMap(array $keys): array - { - $map = []; - foreach ($keys as $key) { - if (!is_string($key) || !str_contains($key, '__')) { - throw new UnknownAliasException("Alias '$key' is invalid"); - } - [$alias, $field] = explode('__', $key, 2); - if (mb_strlen($alias) <= 0 || mb_strlen($field) <= 0) { - throw new UnknownAliasException("Alias '$key' is invalid"); - } - $map[$alias][] = $field; - } - return $map; + $this->mappingStrategy = $mappingStrategy; } /** @@ -153,7 +117,6 @@ protected function map( if (in_array($parentAssociation, ['hasOne', 'belongsTo'])) { if (!$parent->has($node['propertyName'])) { // create new entity - //TODO: do not create entity if all fields are null $entity = $this->constructEntity($className, $row[$alias]); $parent->set($node['propertyName'], $entity); $parent->clean(); @@ -171,7 +134,6 @@ protected function map( $hash = $this->computeFieldsHash($row[$alias], $parentHash); if (!isset($this->entitiesMap[$alias][$hash])) { // create new entity - //TODO: do not create entity if all fields are null $entity = $this->constructEntity($className, $row[$alias]); if ($entity !== null) { $siblings[] = $entity; diff --git a/src/ORM/MappingStrategy.php b/src/ORM/MappingStrategy.php index e00482c..e76939f 100644 --- a/src/ORM/MappingStrategy.php +++ b/src/ORM/MappingStrategy.php @@ -57,7 +57,9 @@ public function __construct(Table $rootTable, array $aliases) $this->unknownAliases = array_combine($aliases, $aliases); $rootAlias = $rootTable->getAlias(); if (!isset($this->unknownAliases[$rootAlias])) { - throw new UnknownAliasException("The query must use root table alias '$rootAlias'"); + $message = "The query must select at least one column from the root table."; + $message .= " The column alias must use {$rootAlias}__{column_name} format"; + throw new UnknownAliasException($message); } unset($this->unknownAliases[$rootAlias]); } @@ -79,7 +81,8 @@ public function build(): self } } if ($this->unknownAliases !== []) { - throw new UnknownAliasException('Failed to map some aliases: ' . $this->unknownAliasesToString()); + $message = sprintf("None of the table associations match alias '%s'", $this->unknownAliasesToString()); + throw new UnknownAliasException($message); } return $this; } @@ -129,10 +132,11 @@ private function scanRootLevel(Table $table): array $result[$type][$alias] = $firstLevelAssoc; } if ($unknownAliasesCount > 0 && $unknownAliasesCount === count($this->unknownAliases)) { - throw new UnknownAliasException( - 'None of the root table associations match any remaining aliases: ' . - $this->unknownAliasesToString() + $message = sprintf( + "None of the root table associations match alias '%s'", + $this->unknownAliasesToString(), ); + throw new UnknownAliasException($message); } return $result; } @@ -164,6 +168,8 @@ private function scanTableRecursive(string $alias): array continue; } unset($this->unknownAliases[$childAlias]); + $result[$type][$childAlias]['className'] = $target->getEntityClass(); + $result[$type][$childAlias]['propertyName'] = $assoc->getProperty(); if ($assoc instanceof BelongsToMany) { $through = $assoc->getThrough() ?? $assoc->junction(); if (is_object($through)) { @@ -219,6 +225,6 @@ public function toArray(): array private function unknownAliasesToString(): string { - return implode(', ', array_keys($this->unknownAliases)); + return implode("', '", array_keys($this->unknownAliases)); } } diff --git a/src/ORM/NativeSQLMapperTrait.php b/src/ORM/NativeSQLMapperTrait.php index d8388fa..c37663a 100644 --- a/src/ORM/NativeSQLMapperTrait.php +++ b/src/ORM/NativeSQLMapperTrait.php @@ -5,7 +5,6 @@ namespace Bancer\NativeQueryMapper\ORM; use Cake\Database\StatementInterface; -use Cake\ORM\Table; trait NativeSQLMapperTrait { diff --git a/src/ORM/StatementQuery.php b/src/ORM/StatementQuery.php index c35c7a2..374800a 100644 --- a/src/ORM/StatementQuery.php +++ b/src/ORM/StatementQuery.php @@ -14,7 +14,7 @@ class StatementQuery protected bool $isExecuted; /** - * @var callable|null + * @var mixed[]|null */ protected $mapStrategy = null; @@ -28,10 +28,10 @@ public function __construct(Table $rootTable, StatementInterface $stmt) /** * Provide a custom mapping strategy. * - * @param callable $strategy + * @param mixed[] $strategy * @return $this */ - public function mapStrategy(callable $strategy): self + public function mapStrategy(array $strategy): self { $this->mapStrategy = $strategy; return $this; @@ -52,10 +52,41 @@ public function all(): array if (!$rows) { return []; } - if ($this->mapStrategy !== null) { - return array_map($this->mapStrategy, $rows); + if ($this->mapStrategy === null) { + $aliases = $this->extractAliases($rows); + $strategy = new MappingStrategy($this->rootTable, $aliases); + $this->mapStrategy = $strategy->build()->toArray(); } - $hydrator = new AutoHydratorRecursive($this->rootTable, $rows); + $hydrator = new AutoHydratorRecursive($this->rootTable, $this->mapStrategy); return $hydrator->hydrateMany($rows); } + + /** + * Extracts aliases of the columns from the query's result set. + * + * @param mixed[] $rows Result set rows. + * @return string[] + */ + protected function extractAliases(array $rows): array + { + $firstRow = $rows[0] ?? []; + if (!is_array($firstRow)) { + throw new \InvalidArgumentException('First element of the result set is not an array'); + } + $keys = array_keys($firstRow); + $aliases = []; + foreach ($keys as $key) { + if (!is_string($key) || !str_contains($key, '__')) { + throw new UnknownAliasException("Column '$key' must use an alias in the format {Alias}__$key"); + } + [$alias, $field] = explode('__', $key, 2); + if (mb_strlen($alias) <= 0 || mb_strlen($field) <= 0) { + $message = "Alias '$key' is invalid. Column alias must use {Alias}__{column_name} format"; + throw new UnknownAliasException($message); + } + $aliases[] = $alias; + } + sort($aliases); + return $aliases; + } } diff --git a/tests/TestApp/Model/Table/UsersTable.php b/tests/TestApp/Model/Table/UsersTable.php index f3c41b6..076191e 100644 --- a/tests/TestApp/Model/Table/UsersTable.php +++ b/tests/TestApp/Model/Table/UsersTable.php @@ -21,5 +21,6 @@ public function initialize(array $config): void parent::initialize($config); $this->belongsTo('Countries', ['className' => CountriesTable::class]); $this->hasOne('Profiles', ['className' => ProfilesTable::class]); + $this->hasMany('Articles', ['className' => ArticlesTable::class]); } } diff --git a/tests/TestCase/ORM/NativeQueryMapperTest.php b/tests/TestCase/ORM/NativeQueryMapperTest.php index cd1069d..7b1da23 100644 --- a/tests/TestCase/ORM/NativeQueryMapperTest.php +++ b/tests/TestCase/ORM/NativeQueryMapperTest.php @@ -5,16 +5,18 @@ namespace Bancer\NativeQueryMapperTest\TestCase; use PHPUnit\Framework\TestCase; +use Bancer\NativeQueryMapper\ORM\UnknownAliasException; use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Article; use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Comment; +use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Country; use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Profile; use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Tag; use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\User; use Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable; use Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable; -use Cake\ORM\Locator\LocatorAwareTrait; -use Bancer\NativeQueryMapper\ORM\UnknownAliasException; +use Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable; use Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable; +use Cake\ORM\Locator\LocatorAwareTrait; class NativeQueryMapperTest extends TestCase { @@ -39,26 +41,95 @@ private function assertEqualsEntities(array $expected, array $actual): void public function testInvalidAlias(): void { $this->expectException(UnknownAliasException::class); - $this->expectExceptionMessage("The query must use root table alias 'Articles'"); + $expectedMessage = "The query must select at least one column from the root table."; + $expectedMessage .= " The column alias must use Articles__{column_name} format"; + $this->expectExceptionMessage($expectedMessage); /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); $stmt = $ArticlesTable->prepareSQL(" SELECT - a.id AS a__id, + a.id AS a__id, a.title AS a__title FROM articles AS a "); $ArticlesTable->fromNativeQuery($stmt)->all(); } + public function testMissingAlias(): void + { + $this->expectException(UnknownAliasException::class); + $this->expectExceptionMessage("Column 'title' must use an alias in the format {Alias}__title"); + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + id AS Articles__id, + title + FROM articles + "); + $ArticlesTable->fromNativeQuery($stmt)->all(); + } + + public function testIncompleteAlias(): void + { + $this->expectException(UnknownAliasException::class); + $this->expectExceptionMessage( + "Alias 'Articles__' is invalid. Column alias must use {Alias}__{column_name} format", + ); + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + id AS Articles__id, + title AS Articles__ + FROM articles + "); + $ArticlesTable->fromNativeQuery($stmt)->all(); + } + + public function testUnrecognizedRootAlias(): void + { + $this->expectException(UnknownAliasException::class); + $this->expectExceptionMessage("None of the root table associations match alias 'Books'"); + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + id AS Articles__id, + title AS Books__title + FROM articles + "); + $ArticlesTable->fromNativeQuery($stmt)->all(); + } + + public function testUnrecognizedChildAlias(): void + { + $this->expectException(UnknownAliasException::class); + $this->expectExceptionMessage("None of the table associations match alias 'Books'"); + /** @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, + Comments.id AS Comments__id, + Comments.article_id AS Comments__article_id, + Comments.content AS Books__content + FROM articles AS Articles + LEFT JOIN comments AS Comments + ON Articles.id=Comments.article_id + "); + $ArticlesTable->fromNativeQuery($stmt)->all(); + } + public function testEmptyResultSet(): 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 + Articles.id AS Articles__id, + Articles.title AS Articles__title FROM articles AS Articles WHERE Articles.title = :title "); @@ -73,8 +144,8 @@ public function testSimplestSelect(): void $ArticlesTable = $this->fetchTable(ArticlesTable::class); $stmt = $ArticlesTable->prepareSQL(" SELECT - Articles.id AS Articles__id, - Articles.title AS Articles__title + Articles.id AS Articles__id, + Articles.title AS Articles__title FROM articles AS Articles "); $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); @@ -84,7 +155,32 @@ public function testSimplestSelect(): void 'id' => 1, 'title' => 'Article 1', ]; - static::assertSame($expected, $actual[0]->toArray()); + static::assertEquals($expected, $actual[0]->toArray()); + $cakeEntities = $ArticlesTable->find() + ->select(['id', 'title']) + ->toArray(); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); + } + + public function testSimplestSelectMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + id AS Articles__id, + title AS Articles__title + FROM articles + "); + $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Article::class, $actual[0]); + $expected = [ + 'id' => 1, + 'title' => 'Article 1', + ]; + static::assertEquals($expected, $actual[0]->toArray()); $cakeEntities = $ArticlesTable->find() ->select(['id', 'title']) ->toArray(); @@ -98,11 +194,11 @@ public function testSelectHasMany(): void $ArticlesTable = $this->fetchTable(ArticlesTable::class); $stmt = $ArticlesTable->prepareSQL(" SELECT - Articles.id AS Articles__id, - Articles.title AS Articles__title, - Comments.id AS Comments__id, + Articles.id AS Articles__id, + Articles.title AS Articles__title, + Comments.id AS Comments__id, Comments.article_id AS Comments__article_id, - Comments.content AS Comments__content + Comments.content AS Comments__content FROM articles AS Articles LEFT JOIN comments AS Comments ON Articles.id=Comments.article_id @@ -130,7 +226,58 @@ public function testSelectHasMany(): void ], ], ]; - static::assertSame($expected, $actual[0]->toArray()); + static::assertEquals($expected, $actual[0]->toArray()); + $cakeEntities = $ArticlesTable->find() + ->select(['Articles.id', 'Articles.title']) + ->contain([ + 'Comments' => [ + 'fields' => ['Comments.id', 'Comments.article_id', 'Comments.content'], + ], + ]) + ->toArray(); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); + } + + public function testSelectHasManyMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + a.id AS Articles__id, + title AS Articles__title, + c.id AS Comments__id, + article_id AS Comments__article_id, + content AS Comments__content + FROM articles AS a + LEFT JOIN comments AS c + ON a.id=c.article_id + "); + $actual = $ArticlesTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Article::class, $actual[0]); + $actualComments = $actual[0]->get('comments'); + static::assertIsArray($actualComments); + static::assertCount(2, $actualComments); + static::assertInstanceOf(Comment::class, $actualComments[0]); + $expected = [ + 'id' => 1, + 'title' => 'Article 1', + 'comments' => [ + [ + 'id' => 1, + 'article_id' => 1, + 'content' => 'Comment 1', + ], + [ + 'id' => 2, + 'article_id' => 1, + 'content' => 'Comment 2', + ], + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); $cakeEntities = $ArticlesTable->find() ->select(['Articles.id', 'Articles.title']) ->contain([ @@ -149,11 +296,11 @@ public function testSelectBelongsTo(): void $CommentsTable = $this->fetchTable(CommentsTable::class); $stmt = $CommentsTable->prepareSQL(" SELECT - Comments.id AS Comments__id, + Comments.id AS Comments__id, Comments.article_id AS Comments__article_id, - Comments.content AS Comments__content, - Articles.id AS Articles__id, - Articles.title AS Articles__title + Comments.content AS Comments__content, + Articles.id AS Articles__id, + Articles.title AS Articles__title FROM comments AS Comments LEFT JOIN articles AS Articles ON Articles.id=Comments.article_id @@ -179,7 +326,48 @@ public function testSelectBelongsTo(): void ], ]) ->toArray(); - static::assertSame($expected, $actual[0]->toArray()); + static::assertEquals($expected, $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); + } + + public function testSelectBelongsToMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ + $CommentsTable = $this->fetchTable(CommentsTable::class); + $stmt = $CommentsTable->prepareSQL(" + SELECT + c.id AS Comments__id, + article_id AS Comments__article_id, + content AS Comments__content, + a.id AS Articles__id, + title AS Articles__title + FROM comments AS c + LEFT JOIN articles AS a + ON a.id=c.article_id + "); + $actual = $CommentsTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Comment::class, $actual[0]); + static::assertInstanceOf(Article::class, $actual[0]->get('article')); + $expected = [ + 'id' => 1, + 'article_id' => 1, + 'content' => 'Comment 1', + 'article' => [ + 'id' => 1, + 'title' => 'Article 1', + ], + ]; + $cakeEntities = $CommentsTable->find() + ->select(['Comments.id', 'Comments.article_id', 'Comments.content']) + ->contain([ + 'Articles' => [ + 'fields' => ['Articles.id', 'Articles.title'], + ], + ]) + ->toArray(); + static::assertEquals($expected, $actual[0]->toArray()); $this->assertEqualsEntities($cakeEntities, $actual); //static::assertEquals($cakeEntities, $actual); } @@ -190,11 +378,11 @@ public function testHasOne(): void $UsersTable = $this->fetchTable(UsersTable::class); $stmt = $UsersTable->prepareSQL(" SELECT - Users.id AS Users__id, - Users.username AS Users__username, - Profiles.id AS Profiles__id, - Profiles.user_id AS Profiles__user_id, - Profiles.bio AS Profiles__bio + Users.id AS Users__id, + Users.username AS Users__username, + Profiles.id AS Profiles__id, + Profiles.user_id AS Profiles__user_id, + Profiles.bio AS Profiles__bio FROM users AS Users LEFT JOIN profiles AS Profiles ON Users.id=Profiles.user_id @@ -212,7 +400,7 @@ public function testHasOne(): void 'bio' => 'Bio Alice', ], ]; - static::assertSame($expected, $actual[0]->toArray()); + static::assertEquals($expected, $actual[0]->toArray()); $cakeEntities = $UsersTable->find() ->select(['Users.id', 'Users.username']) ->contain([ @@ -221,7 +409,49 @@ public function testHasOne(): void ], ]) ->toArray(); - static::assertSame($expected, $actual[0]->toArray()); + static::assertEquals($expected, $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); + } + + public function testHasOneMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\UsersTable $UsersTable */ + $UsersTable = $this->fetchTable(UsersTable::class); + $stmt = $UsersTable->prepareSQL(" + SELECT + u.id AS Users__id, + username AS Users__username, + p.id AS Profiles__id, + user_id AS Profiles__user_id, + bio AS Profiles__bio + FROM users AS u + LEFT JOIN profiles AS p + ON u.id=p.user_id + "); + $actual = $UsersTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(User::class, $actual[0]); + static::assertInstanceOf(Profile::class, $actual[0]->get('profile')); + $expected = [ + 'id' => 1, + 'username' => 'alice', + 'profile' => [ + 'id' => 1, + 'user_id' => 1, + 'bio' => 'Bio Alice', + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); + $cakeEntities = $UsersTable->find() + ->select(['Users.id', 'Users.username']) + ->contain([ + 'Profiles' => [ + 'fields' => ['Profiles.id', 'Profiles.user_id', 'Profiles.bio'], + ], + ]) + ->toArray(); + static::assertEquals($expected, $actual[0]->toArray()); $this->assertEqualsEntities($cakeEntities, $actual); //static::assertEquals($cakeEntities, $actual); } @@ -232,10 +462,10 @@ public function testBelongsToManySimple(): void $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 + Articles.id AS Articles__id, + Articles.title AS Articles__title, + Tags.id AS Tags__id, + Tags.name AS Tags__name FROM articles AS Articles LEFT JOIN articles_tags AS ArticlesTags ON Articles.id=ArticlesTags.article_id @@ -263,7 +493,7 @@ public function testBelongsToManySimple(): void ], ], ]; - static::assertSame($expected, $actual[0]->toArray()); + static::assertEquals($expected, $actual[0]->toArray()); /*$cakeEntities = $ArticlesTable->find() ->select(['Articles.id', 'Articles.title']) ->contain([ @@ -277,19 +507,70 @@ public function testBelongsToManySimple(): void static::assertEquals($cakeEntities, $actual);*/ } + public function testBelongsToManySimpleMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + a.id AS Articles__id, + title AS Articles__title, + t.id AS Tags__id, + name AS Tags__name + FROM articles AS a + LEFT JOIN articles_tags AS at + ON a.id=at.article_id + LEFT JOIN tags AS t + ON t.id=at.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', + ], + [ + 'id' => 2, + 'name' => 'Food', + ], + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); + /*$cakeEntities = $ArticlesTable->find() + ->select(['Articles.id', 'Articles.title']) + ->contain([ + 'Tags' => [ + 'fields' => ['Tags.id', 'Tags.name'], + ], + ]) + ->toArray(); + static::assertSame($expected, $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + static::assertEquals($cakeEntities, $actual);*/ + } + 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, + 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 + ArticlesTags.tag_id AS ArticlesTags__tag_id FROM articles AS Articles LEFT JOIN articles_tags AS ArticlesTags ON Articles.id=ArticlesTags.article_id @@ -327,7 +608,7 @@ public function testBelongsToManyFetchJoinTable(): void ], ], ]; - static::assertSame($expected, $actual[0]->toArray()); + static::assertEquals($expected, $actual[0]->toArray()); /*$cakeEntities = $ArticlesTable->find() ->select(['Articles.id', 'Articles.title']) ->contain([ @@ -343,4 +624,547 @@ public function testBelongsToManyFetchJoinTable(): void $this->assertEqualsEntities($cakeEntities, $actual); static::assertEquals($cakeEntities, $actual);*/ } + + public function testBelongsToManyFetchJoinTableMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + a.id AS Articles__id, + title AS Articles__title, + t.id AS Tags__id, + name AS Tags__name, + at.id AS ArticlesTags__id, + article_id AS ArticlesTags__article_id, + tag_id AS ArticlesTags__tag_id + FROM articles AS a + LEFT JOIN articles_tags AS at + ON a.id=at.article_id + LEFT JOIN tags AS t + ON t.id=at.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', + 'articles_tag' => [ + 'id' => 1, + 'article_id' => 1, + 'tag_id' => 1, + ], + ], + [ + 'id' => 2, + 'name' => 'Food', + 'articles_tag' => [ + 'id' => 2, + 'article_id' => 1, + 'tag_id' => 2, + ], + ], + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); + /*$cakeEntities = $ArticlesTable->find() + ->select(['Articles.id', 'Articles.title']) + ->contain([ + 'Tags' => [ + 'fields' => ['Tags.id', 'Tags.name'], + 'ArticlesTags' => [ + 'fields' => ['ArticlesTags.id', 'ArticlesTags.article_id', 'ArticlesTags.tag_id'], + ], + ], + ]) + ->toArray(); + static::assertSame($cakeEntities[0]->toArray(), $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + static::assertEquals($cakeEntities, $actual);*/ + } + + public function testDeepAssociations(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ + $CountriesTable = $this->fetchTable(CountriesTable::class); + $stmt = $CountriesTable->prepareSQL(" + SELECT + Countries.id AS Countries__id, + Countries.name AS Countries__name, + Users.id AS Users__id, + Users.username AS Users__username, + Users.country_id AS Users__country_id, + Profiles.id AS Profiles__id, + Profiles.bio AS Profiles__bio, + Articles.id AS Articles__id, + Articles.title AS Articles__title, + Articles.user_id AS Articles__user_id, + Comments.id AS Comments__id, + Comments.content AS Comments__content, + Comments.article_id AS Comments__article_id + FROM countries AS Countries + LEFT JOIN users AS Users + ON Users.country_id=Countries.id + LEFT JOIN profiles AS Profiles + ON Profiles.user_id=Users.id + LEFT JOIN articles AS Articles + ON Articles.user_id=Users.id + LEFT JOIN articles_tags AS ArticlesTags + ON Articles.id=ArticlesTags.article_id + LEFT JOIN tags AS Tags + ON Tags.id=ArticlesTags.tag_id + LEFT JOIN comments AS Comments + ON Comments.article_id=Articles.id + "); + $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Country::class, $actual[0]); + $actualUsers = $actual[0]->get('users'); + static::assertIsArray($actualUsers); + static::assertCount(1, $actualUsers); + static::assertInstanceOf(User::class, $actualUsers[0]); + $actualArticles = $actualUsers[0]->get('articles'); + static::assertIsArray($actualArticles); + static::assertCount(1, $actualArticles); + static::assertInstanceOf(Article::class, $actualArticles[0]); + $actualComments = $actualArticles[0]->get('comments'); + static::assertIsArray($actualComments); + static::assertCount(2, $actualComments); + static::assertInstanceOf(Comment::class, $actualComments[0]); + $expected = [ + 'id' => 1, + 'name' => 'USA', + 'users' => [ + [ + 'id' => 1, + 'username' => 'alice', + 'country_id' => 1, + 'profile' => [ + 'id' => 1, + 'bio' => 'Bio Alice', + ], + 'articles' => [ + [ + 'id' => 1, + 'title' => 'Article 1', + 'user_id' => 1, + 'comments' => [ + [ + 'id' => 1, + 'content' => 'Comment 1', + 'article_id' => 1, + ], + [ + 'id' => 2, + 'content' => 'Comment 2', + 'article_id' => 1, + ], + ], + ], + ], + ], + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); + $cakeEntities = $CountriesTable->find() + ->select(['Countries.id', 'Countries.name']) + ->contain([ + 'Users' => [ + 'fields' => ['Users.id', 'Users.username', 'Users.country_id'], + 'Profiles' => [ + 'fields' => ['Profiles.id', 'Profiles.bio'], + ], + 'Articles' => [ + 'fields' => ['Articles.id', 'Articles.title', 'Articles.user_id'], + 'Comments' => [ + 'fields' => ['Comments.id', 'Comments.content', 'Comments.article_id'], + ], + ], + ], + ]) + ->toArray(); + $this->assertEqualsEntities($cakeEntities, $actual); + } + + public function testDeepAssociationsMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ + $CountriesTable = $this->fetchTable(CountriesTable::class); + $stmt = $CountriesTable->prepareSQL(" + SELECT + c.id AS Countries__id, + c.name AS Countries__name, + u.id AS Users__id, + username AS Users__username, + country_id AS Users__country_id, + p.id AS Profiles__id, + bio AS Profiles__bio, + a.id AS Articles__id, + title AS Articles__title, + a.user_id AS Articles__user_id, + cm.id AS Comments__id, + content AS Comments__content, + cm.article_id AS Comments__article_id + FROM countries AS c + LEFT JOIN users AS u + ON u.country_id=c.id + LEFT JOIN profiles AS p + ON p.user_id=u.id + LEFT JOIN articles AS a + ON a.user_id=u.id + LEFT JOIN articles_tags AS at + ON a.id=at.article_id + LEFT JOIN tags AS t + ON t.id=at.tag_id + LEFT JOIN comments AS cm + ON cm.article_id=a.id + "); + $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Country::class, $actual[0]); + $actualUsers = $actual[0]->get('users'); + static::assertIsArray($actualUsers); + static::assertCount(1, $actualUsers); + static::assertInstanceOf(User::class, $actualUsers[0]); + $actualArticles = $actualUsers[0]->get('articles'); + static::assertIsArray($actualArticles); + static::assertCount(1, $actualArticles); + static::assertInstanceOf(Article::class, $actualArticles[0]); + $actualComments = $actualArticles[0]->get('comments'); + static::assertIsArray($actualComments); + static::assertCount(2, $actualComments); + static::assertInstanceOf(Comment::class, $actualComments[0]); + $expected = [ + 'id' => 1, + 'name' => 'USA', + 'users' => [ + [ + 'id' => 1, + 'username' => 'alice', + 'country_id' => 1, + 'profile' => [ + 'id' => 1, + 'bio' => 'Bio Alice', + ], + 'articles' => [ + [ + 'id' => 1, + 'title' => 'Article 1', + 'user_id' => 1, + 'comments' => [ + [ + 'id' => 1, + 'content' => 'Comment 1', + 'article_id' => 1, + ], + [ + 'id' => 2, + 'content' => 'Comment 2', + 'article_id' => 1, + ], + ], + ], + ], + ], + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); + $cakeEntities = $CountriesTable->find() + ->select(['Countries.id', 'Countries.name']) + ->contain([ + 'Users' => [ + 'fields' => ['Users.id', 'Users.username', 'Users.country_id'], + 'Profiles' => [ + 'fields' => ['Profiles.id', 'Profiles.bio'], + ], + 'Articles' => [ + 'fields' => ['Articles.id', 'Articles.title', 'Articles.user_id'], + 'Comments' => [ + 'fields' => ['Comments.id', 'Comments.content', 'Comments.article_id'], + ], + ], + ], + ]) + ->toArray(); + $this->assertEqualsEntities($cakeEntities, $actual); + } + + public function testDeepAssociationsWithBelongsToMany(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ + $CountriesTable = $this->fetchTable(CountriesTable::class); + $stmt = $CountriesTable->prepareSQL(" + SELECT + Countries.id AS Countries__id, + Countries.name AS Countries__name, + Users.id AS Users__id, + Users.username AS Users__username, + Profiles.id AS Profiles__id, + Profiles.bio AS Profiles__bio, + 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, + Comments.id AS Comments__id, + Comments.content AS Comments__content + FROM countries AS Countries + LEFT JOIN users AS Users + ON Users.country_id=Countries.id + LEFT JOIN profiles AS Profiles + ON Profiles.user_id=Users.id + LEFT JOIN articles AS Articles + ON Articles.user_id=Users.id + LEFT JOIN articles_tags AS ArticlesTags + ON Articles.id=ArticlesTags.article_id + LEFT JOIN tags AS Tags + ON Tags.id=ArticlesTags.tag_id + LEFT JOIN comments AS Comments + ON Comments.article_id=Articles.id + "); + $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Country::class, $actual[0]); + $actualUsers = $actual[0]->get('users'); + static::assertIsArray($actualUsers); + static::assertCount(1, $actualUsers); + static::assertInstanceOf(User::class, $actualUsers[0]); + $actualArticles = $actualUsers[0]->get('articles'); + static::assertIsArray($actualArticles); + static::assertCount(1, $actualArticles); + static::assertInstanceOf(Article::class, $actualArticles[0]); + $actualComments = $actualArticles[0]->get('comments'); + static::assertIsArray($actualComments); + static::assertCount(2, $actualComments); + static::assertInstanceOf(Comment::class, $actualComments[0]); + $actualTags = $actualArticles[0]->get('tags'); + static::assertIsArray($actualTags); + static::assertCount(2, $actualTags); + static::assertInstanceOf(Tag::class, $actualTags[0]); + $expected = [ + 'id' => 1, + 'name' => 'USA', + 'users' => [ + [ + 'id' => 1, + 'username' => 'alice', + 'profile' => [ + 'id' => 1, + 'bio' => 'Bio Alice', + ], + 'articles' => [ + [ + 'id' => 1, + 'title' => 'Article 1', + 'comments' => [ + [ + 'id' => 1, + 'content' => 'Comment 1', + ], + [ + 'id' => 2, + 'content' => 'Comment 2', + ], + ], + 'tags' => [ + [ + 'id' => 1, + 'name' => 'Tech', + 'articles_tag' => + [ + 'id' => 1, + 'article_id' => 1, + 'tag_id' => 1, + ], + ], + [ + 'id' => 2, + 'name' => 'Food', + 'articles_tag' => + [ + 'id' => 2, + 'article_id' => 1, + 'tag_id' => 2, + ], + ], + ], + ], + ], + ], + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); + /*$cakeEntities = $CountriesTable->find() + ->select(['Countries.id', 'Countries.name']) + ->contain([ + 'Users' => [ + 'fields' => ['Users.id', 'Users.username', 'Users.country_id'], + 'Profiles' => [ + 'fields' => ['Profiles.id', 'Profiles.bio'], + ], + 'Articles' => [ + 'fields' => ['Articles.id', 'Articles.title', 'Articles.user_id'], + 'Tags' => [ + 'fields' => ['Tags.id', 'Tags.name'], + 'ArticlesTags' => [ + 'fields' => ['ArticlesTags.id', 'ArticlesTags.article_id', 'ArticlesTags.tag_id'], + ], + ], + 'Comments' => [ + 'fields' => ['Comments.id', 'Comments.content', 'Comments.article_id'], + ], + ], + ], + ]) + ->toArray(); + static::assertSame($cakeEntities[0]->toArray(), $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + static::assertEquals($cakeEntities, $actual);*/ + } + + public function testDeepAssociationsWithBelongsToManyMinimalSQL(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CountriesTable $CountriesTable */ + $CountriesTable = $this->fetchTable(CountriesTable::class); + $stmt = $CountriesTable->prepareSQL(" + SELECT + c.id AS Countries__id, + c.name AS Countries__name, + u.id AS Users__id, + username AS Users__username, + p.id AS Profiles__id, + bio AS Profiles__bio, + a.id AS Articles__id, + title AS Articles__title, + t.id AS Tags__id, + t.name AS Tags__name, + at.id AS ArticlesTags__id, + at.article_id AS ArticlesTags__article_id, + tag_id AS ArticlesTags__tag_id, + cm.id AS Comments__id, + content AS Comments__content + FROM countries AS c + LEFT JOIN users AS u + ON u.country_id=c.id + LEFT JOIN profiles AS p + ON p.user_id=u.id + LEFT JOIN articles AS a + ON a.user_id=u.id + LEFT JOIN articles_tags AS at + ON a.id=at.article_id + LEFT JOIN tags AS t + ON t.id=at.tag_id + LEFT JOIN comments AS cm + ON cm.article_id=a.id + "); + $actual = $CountriesTable->fromNativeQuery($stmt)->all(); + static::assertCount(5, $actual); + static::assertInstanceOf(Country::class, $actual[0]); + $actualUsers = $actual[0]->get('users'); + static::assertIsArray($actualUsers); + static::assertCount(1, $actualUsers); + static::assertInstanceOf(User::class, $actualUsers[0]); + $actualArticles = $actualUsers[0]->get('articles'); + static::assertIsArray($actualArticles); + static::assertCount(1, $actualArticles); + static::assertInstanceOf(Article::class, $actualArticles[0]); + $actualComments = $actualArticles[0]->get('comments'); + static::assertIsArray($actualComments); + static::assertCount(2, $actualComments); + static::assertInstanceOf(Comment::class, $actualComments[0]); + $actualTags = $actualArticles[0]->get('tags'); + static::assertIsArray($actualTags); + static::assertCount(2, $actualTags); + static::assertInstanceOf(Tag::class, $actualTags[0]); + $expected = [ + 'id' => 1, + 'name' => 'USA', + 'users' => [ + [ + 'id' => 1, + 'username' => 'alice', + 'profile' => [ + 'id' => 1, + 'bio' => 'Bio Alice', + ], + 'articles' => [ + [ + 'id' => 1, + 'title' => 'Article 1', + 'comments' => [ + [ + 'id' => 1, + 'content' => 'Comment 1', + ], + [ + 'id' => 2, + 'content' => 'Comment 2', + ], + ], + 'tags' => [ + [ + 'id' => 1, + 'name' => 'Tech', + 'articles_tag' => + [ + 'id' => 1, + 'article_id' => 1, + 'tag_id' => 1, + ], + ], + [ + 'id' => 2, + 'name' => 'Food', + 'articles_tag' => + [ + 'id' => 2, + 'article_id' => 1, + 'tag_id' => 2, + ], + ], + ], + ], + ], + ], + ], + ]; + static::assertEquals($expected, $actual[0]->toArray()); + /*$cakeEntities = $CountriesTable->find() + ->select(['Countries.id', 'Countries.name']) + ->contain([ + 'Users' => [ + 'fields' => ['Users.id', 'Users.username', 'Users.country_id'], + 'Profiles' => [ + 'fields' => ['Profiles.id', 'Profiles.bio'], + ], + 'Articles' => [ + 'fields' => ['Articles.id', 'Articles.title', 'Articles.user_id'], + 'Tags' => [ + 'fields' => ['Tags.id', 'Tags.name'], + 'ArticlesTags' => [ + 'fields' => ['ArticlesTags.id', 'ArticlesTags.article_id', 'ArticlesTags.tag_id'], + ], + ], + 'Comments' => [ + 'fields' => ['Comments.id', 'Comments.content', 'Comments.article_id'], + ], + ], + ], + ]) + ->toArray(); + static::assertSame($cakeEntities[0]->toArray(), $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + static::assertEquals($cakeEntities, $actual);*/ + } }