|
5 | 5 | namespace Bancer\NativeQueryMapper\ORM; |
6 | 6 |
|
7 | 7 | use Cake\ORM\Table; |
| 8 | +use Cake\Database\Connection; |
| 9 | +use Cake\Database\FieldTypeConverter; |
| 10 | +use Cake\Database\TypeFactory; |
| 11 | +use Cake\Database\TypeInterface; |
| 12 | +use Cake\Database\TypeMap; |
8 | 13 | use Cake\Datasource\EntityInterface; |
9 | 14 | use Cake\Utility\Hash; |
10 | 15 | use RuntimeException; |
@@ -74,6 +79,22 @@ class RecursiveEntityHydrator |
74 | 79 | */ |
75 | 80 | protected array $entities = []; |
76 | 81 |
|
| 82 | + /** |
| 83 | + * Resolved column type objects indexed by alias and column name. |
| 84 | + * |
| 85 | + * Structure: |
| 86 | + * [ |
| 87 | + * '{alias}' => [ |
| 88 | + * '{column}' => \Cake\Database\TypeInterface|null |
| 89 | + * ] |
| 90 | + * ] |
| 91 | + * |
| 92 | + * A null value indicates that the column exists but has no resolvable type. |
| 93 | + * |
| 94 | + * @var array<string, array<string, \Cake\Database\TypeInterface|null>> |
| 95 | + */ |
| 96 | + protected array $columnTypes = []; |
| 97 | + |
77 | 98 | /** |
78 | 99 | * Whether the presence of primary keys is mandatory for all entities, |
79 | 100 | * inferred automatically based on the mapping strategy. |
@@ -213,17 +234,23 @@ protected function map( |
213 | 234 | } |
214 | 235 |
|
215 | 236 | /** |
216 | | - * Create an entity from raw field data using either: |
217 | | - * - Table marshaller (preferred), or |
218 | | - * - direct entity instantiation (fallback). |
| 237 | + * Constructs an entity instance from raw database fields. |
219 | 238 | * |
220 | | - * Returns null when the row for the alias is "empty" (all NULL fields). |
| 239 | + * This method: |
| 240 | + * - Skips hydration when all fields are NULL (LEFT JOIN safety) |
| 241 | + * - Enforces presence of primary keys when required by mapping strategy |
| 242 | + * - Converts database values to PHP values using table schema types |
| 243 | + * - Instantiates the entity in a "persisted & clean" state |
221 | 244 | * |
222 | 245 | * @param class-string<\Cake\Datasource\EntityInterface> $className Entity class. |
223 | | - * @param mixed[] $fields Raw database fields. |
| 246 | + * @param mixed[] $fields Raw database fields (alias stripped). |
224 | 247 | * @param string $alias Alias of the entity. |
225 | | - * @param string[]|string|null $primaryKey Primary key name(s). |
226 | | - * @return \Cake\Datasource\EntityInterface|null |
| 248 | + * @param string[]|string|null $primaryKey Primary key column name(s), if required. |
| 249 | + * @throws \RuntimeException When primary keys are required but not configured. |
| 250 | + * @throws \Bancer\NativeQueryMapper\ORM\MissingColumnException When required primary key columns are missing |
| 251 | + * from the result set. |
| 252 | + * @return \Cake\Datasource\EntityInterface|null Fully hydrated entity, |
| 253 | + * or null when the row contains only NULL values. |
227 | 254 | */ |
228 | 255 | protected function constructEntity( |
229 | 256 | string $className, |
@@ -258,24 +285,88 @@ protected function constructEntity( |
258 | 285 | } |
259 | 286 | } |
260 | 287 | } |
| 288 | + $options = [ |
| 289 | + 'markClean' => true, |
| 290 | + 'markNew' => false, |
| 291 | + ]; |
261 | 292 | if (isset($this->aliasMap[$alias])) { |
262 | 293 | /** @var \Cake\ORM\Table $Table */ |
263 | 294 | $Table = $this->aliasMap[$alias]; |
264 | | - $options = [ |
265 | | - 'validate' => false, |
| 295 | + $converted = $this->convertDatabaseTypesToPHP($alias, $fields); |
| 296 | + $options += [ |
| 297 | + 'source' => $Table->getRegistryAlias(), |
266 | 298 | ]; |
267 | | - $entity = $Table->marshaller()->one($fields, $options); |
268 | | - $entity->clean(); |
269 | | - $entity->setNew(false); |
270 | | - return $entity; |
| 299 | + return new $className($converted, $options); |
271 | 300 | } |
272 | | - $options = [ |
273 | | - 'markClean' => true, |
274 | | - 'markNew' => false, |
275 | | - ]; |
276 | 301 | return new $className($fields, $options); |
277 | 302 | } |
278 | 303 |
|
| 304 | + /** |
| 305 | + * Converts raw database values to PHP values using the table schema. |
| 306 | + * |
| 307 | + * Each column is converted using its corresponding database type. |
| 308 | + * |
| 309 | + * Column types are resolved lazily and cached per alias to avoid repeated |
| 310 | + * schema lookups and type instantiation. |
| 311 | + * |
| 312 | + * @param string $alias Query alias identifying the table schema. |
| 313 | + * @param mixed[] $fields Raw database field values indexed by column name. |
| 314 | + * @return mixed[] Converted field values suitable for entity construction. |
| 315 | + */ |
| 316 | + protected function convertDatabaseTypesToPHP(string $alias, array $fields): array |
| 317 | + { |
| 318 | + /** @var \Cake\ORM\Table $Table */ |
| 319 | + $Table = $this->aliasMap[$alias]; |
| 320 | + $driver = $Table->getConnection()->getDriver(); |
| 321 | + $converted = []; |
| 322 | + foreach ($fields as $field => $value) { |
| 323 | + if ($value === null) { |
| 324 | + $converted[$field] = $value; |
| 325 | + continue; |
| 326 | + } |
| 327 | + $type = $this->getColumnType($alias, $field); |
| 328 | + if ($type !== null) { |
| 329 | + $converted[$field] = $type->toPHP($value, $driver); |
| 330 | + } else { |
| 331 | + $converted[$field] = $value; |
| 332 | + } |
| 333 | + } |
| 334 | + return $converted; |
| 335 | + } |
| 336 | + |
| 337 | + /** |
| 338 | + * Resolves the database type for a given column. |
| 339 | + * |
| 340 | + * The column type is derived from the table schema associated with |
| 341 | + * the provided alias. The resolved type instance is cached to a class field to avoid |
| 342 | + * repeated schema access and object construction. |
| 343 | + * |
| 344 | + * @param string $alias Query alias used to resolve the table. |
| 345 | + * @param string $columnName Column name within the table. |
| 346 | + * @return \Cake\Database\TypeInterface|null |
| 347 | + * Type instance when resolvable, or null if the column does not exist |
| 348 | + * or has no associated type. |
| 349 | + */ |
| 350 | + protected function getColumnType(string $alias, string $columnName): ?TypeInterface |
| 351 | + { |
| 352 | + if ( |
| 353 | + !array_key_exists($alias, $this->columnTypes) || |
| 354 | + !array_key_exists($columnName, $this->columnTypes[$alias]) |
| 355 | + ) { |
| 356 | + $this->columnTypes[$alias][$columnName] = null; |
| 357 | + /** @var \Cake\ORM\Table $Table */ |
| 358 | + $Table = $this->aliasMap[$alias]; |
| 359 | + $schema = $Table->getSchema(); |
| 360 | + if ($schema->hasColumn($columnName)) { |
| 361 | + $typeName = $schema->getColumnType($columnName); |
| 362 | + if ($typeName !== null) { |
| 363 | + $this->columnTypes[$alias][$columnName] = TypeFactory::build($typeName); |
| 364 | + } |
| 365 | + } |
| 366 | + } |
| 367 | + return $this->columnTypes[$alias][$columnName]; |
| 368 | + } |
| 369 | + |
279 | 370 | /** |
280 | 371 | * Compute a stable hash for an entity's field set, |
281 | 372 | * optionally including the parent entity's hash for hasMany relations. |
|
0 commit comments