66
77use Cake \ORM \Table ;
88use Cake \Datasource \EntityInterface ;
9+ use Cake \Utility \Hash ;
10+ use RuntimeException ;
911
1012class 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}
0 commit comments