Skip to content

Commit fa98f8c

Browse files
committed
Ensure primary keys are present when mapping hasMany and belongsToMany
1 parent 69aceb6 commit fa98f8c

5 files changed

Lines changed: 177 additions & 28 deletions

File tree

src/ORM/AutoHydratorRecursive.php

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use Cake\ORM\Table;
88
use Cake\Datasource\EntityInterface;
9+
use Cake\Utility\Hash;
10+
use RuntimeException;
911

1012
class AutoHydratorRecursive
1113
{
@@ -15,10 +17,10 @@ class AutoHydratorRecursive
1517
* @var string[]
1618
*/
1719
protected array $associationTypes = [
18-
'hasOne',
19-
'belongsTo',
20-
'hasMany',
21-
'belongsToMany',
20+
MappingStrategy::HAS_ONE,
21+
MappingStrategy::BELONGS_TO,
22+
MappingStrategy::HAS_MANY,
23+
MappingStrategy::BELONGS_TO_MANY,
2224
];
2325

2426
/**
@@ -44,6 +46,13 @@ class AutoHydratorRecursive
4446
*/
4547
protected array $entities = [];
4648

49+
/**
50+
* If mapping strategy contains hasMany or belongsToMany association then all mapped models must have primary keys.
51+
*
52+
* @var boolean
53+
*/
54+
protected bool $isPrimaryKeyRequired;
55+
4756
/**
4857
* @param \Cake\ORM\Table $rootTable
4958
* @param mixed[] $mappingStrategy Mapping strategy.
@@ -83,24 +92,25 @@ protected function map(
8392
/** @var array{
8493
* className?: class-string<\Cake\Datasource\EntityInterface>,
8594
* propertyName?: string,
95+
* primaryKey?: string[]|string,
8696
* hasOne?: array<string, mixed[]>,
8797
* belongsTo?: array<string, mixed[]>,
8898
* hasMany?: array<string, mixed[]>,
8999
* belongsToMany?: array<string, mixed[]>
90100
* } $node */
91101
foreach ($mappingStrategy as $alias => $node) {
92102
if (!isset($node['className'])) {
93-
throw new \RuntimeException("Unknown entity class name for alias $alias");
103+
throw new RuntimeException("Unknown entity class name for alias $alias");
94104
}
95105
$className = $node['className'];
96106
if ($parent === null) {
97107
// root entity
98108
$hash = $this->computeFieldsHash($row[$alias]);
99109
if (!isset($this->entitiesMap[$alias][$hash])) {
100110
// create new entity
101-
$entity = $this->constructEntity($className, $row[$alias]);
111+
$entity = $this->constructEntity($className, $row[$alias], $alias, $node['primaryKey'] ?? null);
102112
if ($entity === null) {
103-
throw new \RuntimeException('Failed to construct root entity');
113+
throw new RuntimeException('Failed to construct root entity');
104114
}
105115
$this->entities[] = $entity;
106116
$this->entitiesMap[$alias][$hash] = array_key_last($this->entities);
@@ -112,20 +122,20 @@ protected function map(
112122
} else {
113123
// child entity
114124
if (!isset($node['propertyName'])) {
115-
throw new \RuntimeException("Unknown property name for alias $alias");
125+
throw new RuntimeException("Unknown property name for alias $alias");
116126
}
117-
if (in_array($parentAssociation, ['hasOne', 'belongsTo'])) {
127+
if (in_array($parentAssociation, [MappingStrategy::HAS_ONE, MappingStrategy::BELONGS_TO])) {
118128
if (!$parent->has($node['propertyName'])) {
119129
// create new entity
120-
$entity = $this->constructEntity($className, $row[$alias]);
130+
$entity = $this->constructEntity($className, $row[$alias], $alias, $node['primaryKey'] ?? null);
121131
$parent->set($node['propertyName'], $entity);
122132
$parent->clean();
123133
} else {
124134
// edit already mapped entity
125135
$entity = $parent->get($node['propertyName']);
126136
}
127137
}
128-
if (in_array($parentAssociation, ['hasMany', 'belongsToMany'])) {
138+
if (in_array($parentAssociation, [MappingStrategy::HAS_MANY, MappingStrategy::BELONGS_TO_MANY])) {
129139
$siblings = $parent->get($node['propertyName']);
130140
if (!is_array($siblings)) {
131141
$siblings = [];
@@ -134,7 +144,7 @@ protected function map(
134144
$hash = $this->computeFieldsHash($row[$alias], $parentHash);
135145
if (!isset($this->entitiesMap[$alias][$hash])) {
136146
// create new entity
137-
$entity = $this->constructEntity($className, $row[$alias]);
147+
$entity = $this->constructEntity($className, $row[$alias], $alias, $node['primaryKey'] ?? null);
138148
if ($entity !== null) {
139149
$siblings[] = $entity;
140150
$this->entitiesMap[$alias][$hash] = array_key_last($siblings);
@@ -153,10 +163,10 @@ protected function map(
153163
if (isset($node[$associationType])) {
154164
if (!is_array($node[$associationType])) {
155165
$message = "Association '$associationType' is not an array in mapping strategy";
156-
throw new \RuntimeException($message);
166+
throw new RuntimeException($message);
157167
}
158168
if (!isset($entity) || !($entity instanceof EntityInterface)) {
159-
throw new \RuntimeException('Parent entity must be an instance of EntityInterface');
169+
throw new RuntimeException('Parent entity must be an instance of EntityInterface');
160170
}
161171
$this->map($node[$associationType], $row, $entity, $associationType);
162172
}
@@ -166,12 +176,18 @@ protected function map(
166176
}
167177

168178
/**
169-
* @param class-string<\Cake\Datasource\EntityInterface> $className
170-
* @param mixed[] $fields
179+
* @param class-string<\Cake\Datasource\EntityInterface> $className Entity class name.
180+
* @param mixed[] $fields Entity fields with values.
181+
* @param string $alias Entity alias.
182+
* @param string[]|string|null $primaryKey The name(s) of the primary key column(s).
171183
* @return \Cake\Datasource\EntityInterface|null
172184
*/
173-
protected function constructEntity(string $className, array $fields): ?EntityInterface
174-
{
185+
protected function constructEntity(
186+
string $className,
187+
array $fields,
188+
string $alias,
189+
$primaryKey
190+
): ?EntityInterface {
175191
$isEmpty = true;
176192
foreach ($fields as $value) {
177193
if ($value !== null) {
@@ -182,6 +198,23 @@ protected function constructEntity(string $className, array $fields): ?EntityInt
182198
if ($isEmpty) {
183199
return null;
184200
}
201+
if ($this->isPrimaryKeyRequired()) {
202+
if ($primaryKey === null) {
203+
$message = "Mapping factory must have 'primaryKey' value for each of the mapped models";
204+
$message .= " in order to be able to map 'hasMany' and 'belongsToMany' associations.";
205+
throw new RuntimeException($message);
206+
}
207+
if (is_string($primaryKey)) {
208+
$primaryKey = [$primaryKey];
209+
}
210+
foreach ($primaryKey as $name) {
211+
if (!isset($fields[$name])) {
212+
$primaryKeyString = implode("', '{$alias}__", $primaryKey);
213+
$message = "'{$alias}__{$primaryKeyString}' column must be present in the query's SELECT clause";
214+
throw new MissingColumnException($message);
215+
}
216+
}
217+
}
185218
$options = [
186219
'markClean' => true,
187220
'markNew' => false,
@@ -227,4 +260,28 @@ protected function parse(array $rows): array
227260
}
228261
return $results;
229262
}
263+
264+
/**
265+
* Checks whether the mapping strategy requires all primary keys to be present.
266+
* If mapping strategy contains hasMany or belongsToMany association then all mapped models must have primary keys.
267+
*
268+
* @return bool
269+
*/
270+
protected function isPrimaryKeyRequired(): bool
271+
{
272+
if (!isset($this->isPrimaryKeyRequired)) {
273+
$this->isPrimaryKeyRequired = false;
274+
$flatMap = Hash::flatten($this->mappingStrategy);
275+
$keys = array_keys($flatMap);
276+
foreach ($keys as $name) {
277+
if (
278+
str_contains($name, MappingStrategy::HAS_MANY) ||
279+
str_contains($name, MappingStrategy::BELONGS_TO_MANY)
280+
) {
281+
$this->isPrimaryKeyRequired = true;
282+
}
283+
}
284+
}
285+
return $this->isPrimaryKeyRequired;
286+
}
230287
}

src/ORM/MappingStrategy.php

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ class MappingStrategy
1818
{
1919
use LocatorAwareTrait;
2020

21+
public const BELONGS_TO = 'belongsTo';
22+
23+
public const BELONGS_TO_MANY = 'belongsToMany';
24+
25+
public const HAS_ONE = 'hasOne';
26+
27+
public const HAS_MANY = 'hasMany';
28+
2129
protected Table $rootTable;
2230

2331
/**
@@ -99,6 +107,7 @@ private function scanRootLevel(Table $table): array
99107
/** @var mixed[] $result */
100108
$result = [
101109
'className' => $table->getEntityClass(),
110+
'primaryKey' => $table->getPrimaryKey(),
102111
];
103112
/** @var \Cake\ORM\Association $assoc */
104113
foreach ($table->associations() as $assoc) {
@@ -114,6 +123,7 @@ private function scanRootLevel(Table $table): array
114123
unset($this->unknownAliases[$alias]);
115124
$firstLevelAssoc = [
116125
'className' => $target->getEntityClass(),
126+
'primaryKey' => $target->getPrimaryKey(),
117127
'propertyName' => $assoc->getProperty(),
118128
];
119129
if ($assoc instanceof BelongsToMany) {
@@ -122,8 +132,9 @@ private function scanRootLevel(Table $table): array
122132
$through = $through->getAlias();
123133
}
124134
if (isset($this->unknownAliases[$through])) {
125-
$firstLevelAssoc['hasOne'][$through] = [
135+
$firstLevelAssoc[self::HAS_ONE][$through] = [
126136
'className' => $assoc->junction()->getEntityClass(),
137+
'primaryKey' => $assoc->junction()->getPrimaryKey(),
127138
'propertyName' => Inflector::underscore(Inflector::singularize($through)),
128139
];
129140
unset($this->unknownAliases[$through]);
@@ -156,6 +167,7 @@ private function scanTableRecursive(string $alias): array
156167
/** @var mixed[] $result */
157168
$result = [
158169
'className' => $table->getEntityClass(),
170+
'primaryKey' => $table->getPrimaryKey(),
159171
];
160172
foreach ($table->associations() as $assoc) {
161173
$type = $this->assocType($assoc);
@@ -169,14 +181,16 @@ private function scanTableRecursive(string $alias): array
169181
}
170182
unset($this->unknownAliases[$childAlias]);
171183
$result[$type][$childAlias]['className'] = $target->getEntityClass();
184+
$result[$type][$childAlias]['primaryKey'] = $target->getPrimaryKey();
172185
$result[$type][$childAlias]['propertyName'] = $assoc->getProperty();
173186
if ($assoc instanceof BelongsToMany) {
174187
$through = $assoc->getThrough() ?? $assoc->junction();
175188
if (is_object($through)) {
176189
$through = $through->getAlias();
177190
}
178-
$result[$type][$childAlias]['hasOne'][$through] = [
191+
$result[$type][$childAlias][self::HAS_ONE][$through] = [
179192
'className' => $assoc->junction()->getEntityClass(),
193+
'primaryKey' => $assoc->junction()->getPrimaryKey(),
180194
'propertyName' => Inflector::underscore(Inflector::singularize($through)),
181195
];
182196
if (isset($this->unknownAliases[$through])) {
@@ -200,10 +214,10 @@ private function scanTableRecursive(string $alias): array
200214
private function assocType(Association $assoc): ?string
201215
{
202216
$map = [
203-
HasOne::class => 'hasOne',
204-
BelongsTo::class => 'belongsTo',
205-
BelongsToMany::class => 'belongsToMany',
206-
HasMany::class => 'hasMany',
217+
HasOne::class => self::HAS_ONE,
218+
BelongsTo::class => self::BELONGS_TO,
219+
BelongsToMany::class => self::BELONGS_TO_MANY,
220+
HasMany::class => self::HAS_MANY,
207221
];
208222
foreach ($map as $class => $type) {
209223
if ($assoc instanceof $class) {

src/ORM/MissingColumnException.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bancer\NativeQueryMapper\ORM;
6+
7+
class MissingColumnException extends \RuntimeException
8+
{
9+
}

tests/TestCase/ORM/MappingStrategyTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,23 @@ public function testDeepAssociations(): void
3737
$expected = [
3838
'Articles' => [
3939
'className' => Article::class,
40+
'primaryKey' => 'id',
4041
'belongsTo' => [
4142
'Users' => [
4243
'className' => User::class,
44+
'primaryKey' => 'id',
4345
'propertyName' => 'user',
4446
'belongsTo' => [
4547
'Countries' => [
4648
'className' => Country::class,
49+
'primaryKey' => 'id',
4750
'propertyName' => 'country',
4851
],
4952
],
5053
'hasOne' => [
5154
'Profiles' => [
5255
'className' => Profile::class,
56+
'primaryKey' => 'id',
5357
'propertyName' => 'profile',
5458
],
5559
],
@@ -58,10 +62,12 @@ public function testDeepAssociations(): void
5862
'belongsToMany' => [
5963
'Tags' => [
6064
'className' => Tag::class,
65+
'primaryKey' => 'id',
6166
'propertyName' => 'tags',
6267
'hasOne' => [
6368
'ArticlesTags' => [
6469
'className' => Entity::class,
70+
'primaryKey' => 'id',
6571
'propertyName' => 'articles_tag',
6672
],
6773
],
@@ -70,6 +76,7 @@ public function testDeepAssociations(): void
7076
'hasMany' => [
7177
'Comments' => [
7278
'className' => Comment::class,
79+
'primaryKey' => 'id',
7380
'propertyName' => 'comments',
7481
],
7582
],
@@ -90,9 +97,11 @@ public function testBelongsToMany(): void
9097
$expected = [
9198
'Articles' => [
9299
'className' => Article::class,
100+
'primaryKey' => 'id',
93101
'belongsToMany' => [
94102
'Tags' => [
95103
'className' => Tag::class,
104+
'primaryKey' => 'id',
96105
'propertyName' => 'tags',
97106
],
98107
],
@@ -114,13 +123,16 @@ public function testBelongsToManyFetchJoinTable(): void
114123
$expected = [
115124
'Articles' => [
116125
'className' => Article::class,
126+
'primaryKey' => 'id',
117127
'belongsToMany' => [
118128
'Tags' => [
119129
'className' => Tag::class,
130+
'primaryKey' => 'id',
120131
'propertyName' => 'tags',
121132
'hasOne' => [
122133
'ArticlesTags' => [
123134
'className' => Entity::class,
135+
'primaryKey' => 'id',
124136
'propertyName' => 'articles_tag',
125137
],
126138
],

0 commit comments

Comments
 (0)