- 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
322 lines
11 KiB
PHP
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);
|
|
}
|
|
}
|