Files
michaelschiemer/src/Framework/Database/BatchRelationLoader.php
Michael Schiemer 5050c7d73a docs: consolidate documentation into organized structure
- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
2025-10-05 11:05:04 +02:00

322 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\Metadata\PropertyMetadata;
final readonly class BatchRelationLoader
{
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private IdentityMap $identityMap,
private HydratorInterface $hydrator,
private TypeResolver $typeResolver
) {
}
/**
* Preload a specific relation for multiple entities in batches
*
* @param array<object> $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<object> $entities
* @param string $foreignKeyProperty
* @return array<mixed, array<object>>
*/
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<object> $entities
* @return array<mixed, object>
*/
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<object>
*/
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);
}
}