Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 75 additions & 18 deletions src/ORM/AutoHydratorRecursive.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Cake\ORM\Table;
use Cake\Datasource\EntityInterface;
use Cake\Utility\Hash;
use RuntimeException;

class AutoHydratorRecursive
{
Expand All @@ -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,
];

/**
Expand All @@ -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.
Expand Down Expand Up @@ -83,24 +92,25 @@ protected function map(
/** @var array{
* className?: class-string<\Cake\Datasource\EntityInterface>,
* propertyName?: string,
* primaryKey?: string[]|string,
* hasOne?: array<string, mixed[]>,
* belongsTo?: array<string, mixed[]>,
* hasMany?: array<string, mixed[]>,
* belongsToMany?: array<string, mixed[]>
* } $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) {
// root entity
$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);
Expand All @@ -112,20 +122,20 @@ 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 {
// edit already mapped entity
$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 = [];
Expand All @@ -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);
Expand All @@ -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);
}
Expand All @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
}
26 changes: 20 additions & 6 deletions src/ORM/MappingStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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]);
Expand Down Expand Up @@ -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);
Expand All @@ -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])) {
Expand All @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions src/ORM/MissingColumnException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Bancer\NativeQueryMapper\ORM;

class MissingColumnException extends \RuntimeException
{
}
12 changes: 12 additions & 0 deletions tests/TestCase/ORM/MappingStrategyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,23 @@ public function testDeepAssociations(): void
$expected = [
'Articles' => [
'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',
],
],
Expand All @@ -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',
],
],
Expand All @@ -70,6 +76,7 @@ public function testDeepAssociations(): void
'hasMany' => [
'Comments' => [
'className' => Comment::class,
'primaryKey' => 'id',
'propertyName' => 'comments',
],
],
Expand All @@ -90,9 +97,11 @@ public function testBelongsToMany(): void
$expected = [
'Articles' => [
'className' => Article::class,
'primaryKey' => 'id',
'belongsToMany' => [
'Tags' => [
'className' => Tag::class,
'primaryKey' => 'id',
'propertyName' => 'tags',
],
],
Expand All @@ -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',
],
],
Expand Down
Loading