diff --git a/src/ORM/AutoHydratorRecursive.php b/src/ORM/AutoHydratorRecursive.php index 385bb9b..35afebf 100644 --- a/src/ORM/AutoHydratorRecursive.php +++ b/src/ORM/AutoHydratorRecursive.php @@ -6,6 +6,8 @@ use Cake\ORM\Table; use Cake\Datasource\EntityInterface; +use Cake\Utility\Hash; +use RuntimeException; class AutoHydratorRecursive { @@ -15,10 +17,10 @@ class AutoHydratorRecursive * @var string[] */ protected array $associationTypes = [ - 'hasOne', - 'belongsTo', - 'hasMany', - 'belongsToMany', + MappingStrategy::HAS_ONE, + MappingStrategy::BELONGS_TO, + MappingStrategy::HAS_MANY, + MappingStrategy::BELONGS_TO_MANY, ]; /** @@ -44,6 +46,13 @@ class AutoHydratorRecursive */ protected array $entities = []; + /** + * If mapping strategy contains hasMany or belongsToMany association then all mapped models must have primary keys. + * + * @var boolean + */ + protected bool $isPrimaryKeyRequired; + /** * @param \Cake\ORM\Table $rootTable * @param mixed[] $mappingStrategy Mapping strategy. @@ -83,6 +92,7 @@ protected function map( /** @var array{ * className?: class-string<\Cake\Datasource\EntityInterface>, * propertyName?: string, + * primaryKey?: string[]|string, * hasOne?: array, * belongsTo?: array, * hasMany?: array, @@ -90,7 +100,7 @@ protected function map( * } $node */ foreach ($mappingStrategy as $alias => $node) { if (!isset($node['className'])) { - throw new \RuntimeException("Unknown entity class name for alias $alias"); + throw new RuntimeException("Unknown entity class name for alias $alias"); } $className = $node['className']; if ($parent === null) { @@ -98,9 +108,9 @@ protected function map( $hash = $this->computeFieldsHash($row[$alias]); if (!isset($this->entitiesMap[$alias][$hash])) { // create new entity - $entity = $this->constructEntity($className, $row[$alias]); + $entity = $this->constructEntity($className, $row[$alias], $alias, $node['primaryKey'] ?? null); if ($entity === null) { - throw new \RuntimeException('Failed to construct root entity'); + throw new RuntimeException('Failed to construct root entity'); } $this->entities[] = $entity; $this->entitiesMap[$alias][$hash] = array_key_last($this->entities); @@ -112,12 +122,12 @@ protected function map( } else { // child entity if (!isset($node['propertyName'])) { - throw new \RuntimeException("Unknown property name for alias $alias"); + throw new RuntimeException("Unknown property name for alias $alias"); } - if (in_array($parentAssociation, ['hasOne', 'belongsTo'])) { + if (in_array($parentAssociation, [MappingStrategy::HAS_ONE, MappingStrategy::BELONGS_TO])) { if (!$parent->has($node['propertyName'])) { // create new entity - $entity = $this->constructEntity($className, $row[$alias]); + $entity = $this->constructEntity($className, $row[$alias], $alias, $node['primaryKey'] ?? null); $parent->set($node['propertyName'], $entity); $parent->clean(); } else { @@ -125,7 +135,7 @@ protected function map( $entity = $parent->get($node['propertyName']); } } - if (in_array($parentAssociation, ['hasMany', 'belongsToMany'])) { + if (in_array($parentAssociation, [MappingStrategy::HAS_MANY, MappingStrategy::BELONGS_TO_MANY])) { $siblings = $parent->get($node['propertyName']); if (!is_array($siblings)) { $siblings = []; @@ -134,7 +144,7 @@ protected function map( $hash = $this->computeFieldsHash($row[$alias], $parentHash); if (!isset($this->entitiesMap[$alias][$hash])) { // create new entity - $entity = $this->constructEntity($className, $row[$alias]); + $entity = $this->constructEntity($className, $row[$alias], $alias, $node['primaryKey'] ?? null); if ($entity !== null) { $siblings[] = $entity; $this->entitiesMap[$alias][$hash] = array_key_last($siblings); @@ -153,10 +163,10 @@ protected function map( if (isset($node[$associationType])) { if (!is_array($node[$associationType])) { $message = "Association '$associationType' is not an array in mapping strategy"; - throw new \RuntimeException($message); + throw new RuntimeException($message); } if (!isset($entity) || !($entity instanceof EntityInterface)) { - throw new \RuntimeException('Parent entity must be an instance of EntityInterface'); + throw new RuntimeException('Parent entity must be an instance of EntityInterface'); } $this->map($node[$associationType], $row, $entity, $associationType); } @@ -166,12 +176,18 @@ protected function map( } /** - * @param class-string<\Cake\Datasource\EntityInterface> $className - * @param mixed[] $fields + * @param class-string<\Cake\Datasource\EntityInterface> $className Entity class name. + * @param mixed[] $fields Entity fields with values. + * @param string $alias Entity alias. + * @param string[]|string|null $primaryKey The name(s) of the primary key column(s). * @return \Cake\Datasource\EntityInterface|null */ - protected function constructEntity(string $className, array $fields): ?EntityInterface - { + protected function constructEntity( + string $className, + array $fields, + string $alias, + $primaryKey + ): ?EntityInterface { $isEmpty = true; foreach ($fields as $value) { if ($value !== null) { @@ -182,6 +198,23 @@ protected function constructEntity(string $className, array $fields): ?EntityInt if ($isEmpty) { return null; } + if ($this->isPrimaryKeyRequired()) { + if ($primaryKey === null) { + $message = "Mapping factory must have 'primaryKey' value for each of the mapped models"; + $message .= " in order to be able to map 'hasMany' and 'belongsToMany' associations."; + throw new RuntimeException($message); + } + if (is_string($primaryKey)) { + $primaryKey = [$primaryKey]; + } + foreach ($primaryKey as $name) { + if (!isset($fields[$name])) { + $primaryKeyString = implode("', '{$alias}__", $primaryKey); + $message = "'{$alias}__{$primaryKeyString}' column must be present in the query's SELECT clause"; + throw new MissingColumnException($message); + } + } + } $options = [ 'markClean' => true, 'markNew' => false, @@ -227,4 +260,28 @@ protected function parse(array $rows): array } return $results; } + + /** + * Checks whether the mapping strategy requires all primary keys to be present. + * If mapping strategy contains hasMany or belongsToMany association then all mapped models must have primary keys. + * + * @return bool + */ + protected function isPrimaryKeyRequired(): bool + { + if (!isset($this->isPrimaryKeyRequired)) { + $this->isPrimaryKeyRequired = false; + $flatMap = Hash::flatten($this->mappingStrategy); + $keys = array_keys($flatMap); + foreach ($keys as $name) { + if ( + str_contains($name, MappingStrategy::HAS_MANY) || + str_contains($name, MappingStrategy::BELONGS_TO_MANY) + ) { + $this->isPrimaryKeyRequired = true; + } + } + } + return $this->isPrimaryKeyRequired; + } } diff --git a/src/ORM/MappingStrategy.php b/src/ORM/MappingStrategy.php index e76939f..0e70c68 100644 --- a/src/ORM/MappingStrategy.php +++ b/src/ORM/MappingStrategy.php @@ -18,6 +18,14 @@ class MappingStrategy { use LocatorAwareTrait; + public const BELONGS_TO = 'belongsTo'; + + public const BELONGS_TO_MANY = 'belongsToMany'; + + public const HAS_ONE = 'hasOne'; + + public const HAS_MANY = 'hasMany'; + protected Table $rootTable; /** @@ -99,6 +107,7 @@ private function scanRootLevel(Table $table): array /** @var mixed[] $result */ $result = [ 'className' => $table->getEntityClass(), + 'primaryKey' => $table->getPrimaryKey(), ]; /** @var \Cake\ORM\Association $assoc */ foreach ($table->associations() as $assoc) { @@ -114,6 +123,7 @@ private function scanRootLevel(Table $table): array unset($this->unknownAliases[$alias]); $firstLevelAssoc = [ 'className' => $target->getEntityClass(), + 'primaryKey' => $target->getPrimaryKey(), 'propertyName' => $assoc->getProperty(), ]; if ($assoc instanceof BelongsToMany) { @@ -122,8 +132,9 @@ private function scanRootLevel(Table $table): array $through = $through->getAlias(); } if (isset($this->unknownAliases[$through])) { - $firstLevelAssoc['hasOne'][$through] = [ + $firstLevelAssoc[self::HAS_ONE][$through] = [ 'className' => $assoc->junction()->getEntityClass(), + 'primaryKey' => $assoc->junction()->getPrimaryKey(), 'propertyName' => Inflector::underscore(Inflector::singularize($through)), ]; unset($this->unknownAliases[$through]); @@ -156,6 +167,7 @@ private function scanTableRecursive(string $alias): array /** @var mixed[] $result */ $result = [ 'className' => $table->getEntityClass(), + 'primaryKey' => $table->getPrimaryKey(), ]; foreach ($table->associations() as $assoc) { $type = $this->assocType($assoc); @@ -169,14 +181,16 @@ private function scanTableRecursive(string $alias): array } unset($this->unknownAliases[$childAlias]); $result[$type][$childAlias]['className'] = $target->getEntityClass(); + $result[$type][$childAlias]['primaryKey'] = $target->getPrimaryKey(); $result[$type][$childAlias]['propertyName'] = $assoc->getProperty(); if ($assoc instanceof BelongsToMany) { $through = $assoc->getThrough() ?? $assoc->junction(); if (is_object($through)) { $through = $through->getAlias(); } - $result[$type][$childAlias]['hasOne'][$through] = [ + $result[$type][$childAlias][self::HAS_ONE][$through] = [ 'className' => $assoc->junction()->getEntityClass(), + 'primaryKey' => $assoc->junction()->getPrimaryKey(), 'propertyName' => Inflector::underscore(Inflector::singularize($through)), ]; if (isset($this->unknownAliases[$through])) { @@ -200,10 +214,10 @@ private function scanTableRecursive(string $alias): array private function assocType(Association $assoc): ?string { $map = [ - HasOne::class => 'hasOne', - BelongsTo::class => 'belongsTo', - BelongsToMany::class => 'belongsToMany', - HasMany::class => 'hasMany', + HasOne::class => self::HAS_ONE, + BelongsTo::class => self::BELONGS_TO, + BelongsToMany::class => self::BELONGS_TO_MANY, + HasMany::class => self::HAS_MANY, ]; foreach ($map as $class => $type) { if ($assoc instanceof $class) { diff --git a/src/ORM/MissingColumnException.php b/src/ORM/MissingColumnException.php new file mode 100644 index 0000000..f3e7c36 --- /dev/null +++ b/src/ORM/MissingColumnException.php @@ -0,0 +1,9 @@ + [ 'className' => Article::class, + 'primaryKey' => 'id', 'belongsTo' => [ 'Users' => [ 'className' => User::class, + 'primaryKey' => 'id', 'propertyName' => 'user', 'belongsTo' => [ 'Countries' => [ 'className' => Country::class, + 'primaryKey' => 'id', 'propertyName' => 'country', ], ], 'hasOne' => [ 'Profiles' => [ 'className' => Profile::class, + 'primaryKey' => 'id', 'propertyName' => 'profile', ], ], @@ -58,10 +62,12 @@ public function testDeepAssociations(): void 'belongsToMany' => [ 'Tags' => [ 'className' => Tag::class, + 'primaryKey' => 'id', 'propertyName' => 'tags', 'hasOne' => [ 'ArticlesTags' => [ 'className' => Entity::class, + 'primaryKey' => 'id', 'propertyName' => 'articles_tag', ], ], @@ -70,6 +76,7 @@ public function testDeepAssociations(): void 'hasMany' => [ 'Comments' => [ 'className' => Comment::class, + 'primaryKey' => 'id', 'propertyName' => 'comments', ], ], @@ -90,9 +97,11 @@ public function testBelongsToMany(): void $expected = [ 'Articles' => [ 'className' => Article::class, + 'primaryKey' => 'id', 'belongsToMany' => [ 'Tags' => [ 'className' => Tag::class, + 'primaryKey' => 'id', 'propertyName' => 'tags', ], ], @@ -114,13 +123,16 @@ public function testBelongsToManyFetchJoinTable(): void $expected = [ 'Articles' => [ 'className' => Article::class, + 'primaryKey' => 'id', 'belongsToMany' => [ 'Tags' => [ 'className' => Tag::class, + 'primaryKey' => 'id', 'propertyName' => 'tags', 'hasOne' => [ 'ArticlesTags' => [ 'className' => Entity::class, + 'primaryKey' => 'id', 'propertyName' => 'articles_tag', ], ], diff --git a/tests/TestCase/ORM/NativeQueryMapperTest.php b/tests/TestCase/ORM/NativeQueryMapperTest.php index 7b1da23..bffc304 100644 --- a/tests/TestCase/ORM/NativeQueryMapperTest.php +++ b/tests/TestCase/ORM/NativeQueryMapperTest.php @@ -5,6 +5,7 @@ namespace Bancer\NativeQueryMapperTest\TestCase; use PHPUnit\Framework\TestCase; +use Bancer\NativeQueryMapper\ORM\MissingColumnException; use Bancer\NativeQueryMapper\ORM\UnknownAliasException; use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Article; use Bancer\NativeQueryMapperTest\TestApp\Model\Entity\Comment; @@ -188,7 +189,26 @@ public function testSimplestSelectMinimalSQL(): void //static::assertEquals($cakeEntities, $actual); } - public function testSelectHasMany(): void + public function testHasManyWithoutIdColumn(): void + { + $this->expectException(MissingColumnException::class); + $this->expectExceptionMessage("'Articles__id' column must be present in the query's SELECT clause"); + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ + $ArticlesTable = $this->fetchTable(ArticlesTable::class); + $stmt = $ArticlesTable->prepareSQL(" + SELECT + Articles.title AS Articles__title, + Comments.id AS Comments__id, + Comments.article_id AS Comments__article_id, + Comments.content AS Comments__content + FROM articles AS Articles + LEFT JOIN comments AS Comments + ON Articles.id=Comments.article_id + "); + $ArticlesTable->fromNativeQuery($stmt)->all(); + } + + public function testHasMany(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); @@ -239,7 +259,7 @@ public function testSelectHasMany(): void //static::assertEquals($cakeEntities, $actual); } - public function testSelectHasManyMinimalSQL(): void + public function testHasManyMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\ArticlesTable $ArticlesTable */ $ArticlesTable = $this->fetchTable(ArticlesTable::class); @@ -290,7 +310,7 @@ public function testSelectHasManyMinimalSQL(): void //static::assertEquals($cakeEntities, $actual); } - public function testSelectBelongsTo(): void + public function testBelongsTo(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ $CommentsTable = $this->fetchTable(CommentsTable::class); @@ -331,7 +351,44 @@ public function testSelectBelongsTo(): void //static::assertEquals($cakeEntities, $actual); } - public function testSelectBelongsToMinimalSQL(): void + public function testBelongsToWithoutIdColumns(): void + { + /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ + $CommentsTable = $this->fetchTable(CommentsTable::class); + $stmt = $CommentsTable->prepareSQL(" + SELECT + article_id AS Comments__article_id, + content AS Comments__content, + title AS Articles__title + FROM comments + LEFT JOIN articles + ON articles.id=comments.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 = [ + 'article_id' => 1, + 'content' => 'Comment 1', + 'article' => [ + 'title' => 'Article 1', + ], + ]; + $cakeEntities = $CommentsTable->find() + ->select(['Comments.article_id', 'Comments.content']) + ->contain([ + 'Articles' => [ + 'fields' => ['Articles.title'], + ], + ]) + ->toArray(); + static::assertEquals($expected, $actual[0]->toArray()); + $this->assertEqualsEntities($cakeEntities, $actual); + //static::assertEquals($cakeEntities, $actual); + } + + public function testBelongsToMinimalSQL(): void { /** @var \Bancer\NativeQueryMapperTest\TestApp\Model\Table\CommentsTable $CommentsTable */ $CommentsTable = $this->fetchTable(CommentsTable::class);