$entities * @param string $relationName */ public function preloadRelation(array $entities, string $relationName): void { if (empty($entities)) { return; } $entityClass = get_class($entities[0]); $metadata = $this->metadataRegistry->getMetadata($entityClass); $relationMetadata = $this->getRelationMetadata($metadata, $relationName); if ($relationMetadata === null) { return; // Relation not found } match ($relationMetadata->relationType) { 'hasMany' => $this->preloadHasMany($entities, $relationMetadata), 'belongsTo' => $this->preloadBelongsTo($entities, $relationMetadata), 'one-to-one' => $this->preloadOneToOne($entities, $relationMetadata), default => null }; } /** * Preload hasMany relations (1:N) * Example: Post -> Comments */ private function preloadHasMany(array $entities, PropertyMetadata $relationMetadata): void { // Collect all local keys (e.g., post IDs) $localKeys = array_filter(array_map( fn ($entity) => $this->getLocalKey($entity, $relationMetadata), $entities )); if (empty($localKeys)) { return; } // Load all related entities in one query // WHERE foreign_key IN (1, 2, 3, ...) $allRelations = $this->findByQuery( $relationMetadata->relationTargetClass, [$relationMetadata->relationForeignKey => $localKeys] ); // Group relations by foreign key $groupedRelations = $this->groupByForeignKey($allRelations, $relationMetadata->relationForeignKey); // Set relations on entities foreach ($entities as $entity) { $localKey = $this->getLocalKey($entity, $relationMetadata); $keyString = $this->convertKeyForArray($localKey); $relations = $groupedRelations[$keyString] ?? []; $this->setRelationOnEntity($entity, $relationMetadata->name, $relations); } } /** * Preload belongsTo relations (N:1) * Example: Comment -> Post */ private function preloadBelongsTo(array $entities, PropertyMetadata $relationMetadata): void { // Collect all foreign keys (e.g., post IDs from comments) $foreignKeys = array_filter(array_unique(array_map( fn ($entity) => $this->getForeignKey($entity, $relationMetadata), $entities ))); if (empty($foreignKeys)) { return; } // Load all related entities in one query // WHERE id IN (1, 2, 3, ...) $allRelations = $this->findByQuery( $relationMetadata->relationTargetClass, ['id' => $foreignKeys] // Assuming 'id' is the primary key ); // Index by primary key $indexedRelations = $this->indexByPrimaryKey($allRelations); // Set relations on entities foreach ($entities as $entity) { $foreignKey = $this->getForeignKey($entity, $relationMetadata); $keyString = $this->convertKeyForArray($foreignKey); $relation = $indexedRelations[$keyString] ?? null; $this->setRelationOnEntity($entity, $relationMetadata->name, $relation); } } /** * Preload one-to-one relations * Example: User -> Profile */ private function preloadOneToOne(array $entities, PropertyMetadata $relationMetadata): void { // Similar to belongsTo but expecting single result $localKeys = array_filter(array_map( fn ($entity) => $this->getLocalKey($entity, $relationMetadata), $entities )); if (empty($localKeys)) { return; } $allRelations = $this->findByQuery( $relationMetadata->relationTargetClass, [$relationMetadata->relationForeignKey => $localKeys] ); // Group by foreign key but expect only one result per key $groupedRelations = $this->groupByForeignKey($allRelations, $relationMetadata->relationForeignKey); foreach ($entities as $entity) { $localKey = $this->getLocalKey($entity, $relationMetadata); $keyString = $this->convertKeyForArray($localKey); $relation = $groupedRelations[$keyString][0] ?? null; // Take first (should be only one) $this->setRelationOnEntity($entity, $relationMetadata->name, $relation); } } private function getRelationMetadata(EntityMetadata $metadata, string $relationName): ?PropertyMetadata { foreach ($metadata->properties as $property) { if ($property->name === $relationName && $property->isRelation) { return $property; } } return null; } private function getLocalKey(object $entity, PropertyMetadata $relationMetadata): mixed { return $this->getPropertyValue($entity, $relationMetadata->relationLocalKey); } private function getForeignKey(object $entity, PropertyMetadata $relationMetadata): mixed { return $this->getPropertyValue($entity, $relationMetadata->relationForeignKey); } private function getPropertyValue(object $entity, string $propertyName): mixed { $reflection = new \ReflectionClass($entity); // Try direct property access first if ($reflection->hasProperty($propertyName)) { $property = $reflection->getProperty($propertyName); return $property->getValue($entity); } // Try getter method $getterMethod = 'get' . ucfirst($propertyName); if ($reflection->hasMethod($getterMethod)) { return $entity->$getterMethod(); } // Try ID property as fallback for primary keys if ($propertyName === 'id' && $reflection->hasProperty('id')) { $property = $reflection->getProperty('id'); return $property->getValue($entity); } return null; } /** * Group entities by foreign key value * @param array $entities * @param string $foreignKeyProperty * @return array> */ private function groupByForeignKey(array $entities, string $foreignKeyProperty): array { $grouped = []; foreach ($entities as $entity) { $key = $this->getPropertyValue($entity, $foreignKeyProperty); if ($key !== null) { $keyString = $this->convertKeyForArray($key); $grouped[$keyString][] = $entity; } } return $grouped; } /** * Index entities by their primary key * @param array $entities * @return array */ private function indexByPrimaryKey(array $entities): array { $indexed = []; foreach ($entities as $entity) { $key = $this->getPropertyValue($entity, 'id'); // Assuming 'id' is primary key if ($key !== null) { $keyString = $this->convertKeyForArray($key); $indexed[$keyString] = $entity; } } return $indexed; } /** * Internal method to execute database query and hydrate entities * @param string $entityClass * @param array $criteria * @return array */ private function findByQuery(string $entityClass, array $criteria): array { $metadata = $this->metadataRegistry->getMetadata($entityClass); $query = "SELECT * FROM {$metadata->tableName}"; $params = []; if (! empty($criteria)) { $conditions = []; foreach ($criteria as $field => $value) { if (is_array($value)) { // Handle IN queries for batch loading $placeholders = str_repeat('?,', count($value) - 1) . '?'; $columnName = $metadata->getColumnName($field); $conditions[] = "{$columnName} IN ({$placeholders})"; $params = array_merge($params, array_values($value)); } else { $columnName = $metadata->getColumnName($field); $conditions[] = "{$columnName} = ?"; $params[] = $value; } } $query .= " WHERE " . implode(' AND ', $conditions); } $sqlQuery = ValueObjects\SqlQuery::create($query, $params); $result = $this->databaseManager->getConnection()->query($sqlQuery); $entities = []; foreach ($result->fetchAll() as $data) { $idValue = $data[$metadata->idColumn]; // Check identity map first to avoid duplicates if ($this->identityMap->has($entityClass, $idValue)) { $entities[] = $this->identityMap->get($entityClass, $idValue); } else { $entity = $this->hydrator->hydrate($metadata, $data); $this->identityMap->set($entityClass, $idValue, $entity); $entities[] = $entity; } } return $entities; } private function setRelationOnEntity(object $entity, string $relationName, mixed $relationValue): void { $reflection = new \ReflectionClass($entity); // Try setter methods first $setterMethod = 'set' . ucfirst($relationName); if ($reflection->hasMethod($setterMethod)) { $entity->$setterMethod($relationValue); return; } // For readonly entities, we might need to use a different approach // This is tricky with readonly properties - we might need to store in a cache // For now, we'll skip setting on readonly entities if ($reflection->hasProperty($relationName)) { $property = $reflection->getProperty($relationName); if (! $property->isReadOnly()) { $property->setValue($entity, $relationValue); } } } /** * Convert a key to a string for use as array index * Uses TypeResolver to handle Value Objects like ULID properly */ private function convertKeyForArray(mixed $key): string { return $this->typeResolver->toArrayKey($key); } }