Files
michaelschiemer/src/Framework/Database/EntityManager.php
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

890 lines
28 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Attributes\Singleton;
use App\Framework\Database\Cache\EntityCacheManager;
use App\Framework\Database\Criteria\Criteria;
use App\Framework\Database\Criteria\CriteriaQuery;
use App\Framework\Database\Events\EntityEventManager;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\QueryBuilder\QueryBuilderFactory;
use App\Framework\Database\QueryBuilder\SelectQueryBuilder;
use App\Framework\Database\UnitOfWork\UnitOfWork;
#[Singleton]
final readonly class EntityManager implements EntityLoaderInterface
{
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private TypeConverter $typeConverter,
private IdentityMap $identityMap,
private LazyLoader $lazyLoader,
private HydratorInterface $hydrator,
private BatchRelationLoader $batchRelationLoader,
public UnitOfWork $unitOfWork,
private QueryBuilderFactory $queryBuilderFactory,
private EntityEventManager $entityEventManager,
private ?EntityCacheManager $cacheManager = null
) {
}
/**
* Findet Entity - standardmäßig lazy loading
*/
public function find(string $entityClass, mixed $id): ?object
{
// Use cache manager if available
if ($this->cacheManager !== null) {
return $this->cacheManager->findEntity($entityClass, $id, function () use ($entityClass, $id) {
return $this->findWithLazyLoading($entityClass, $id);
});
}
// Fallback to direct loading
// Prüfe Identity Map zuerst
if ($this->identityMap->has($entityClass, $id)) {
return $this->identityMap->get($entityClass, $id);
}
return $this->findWithLazyLoading($entityClass, $id);
}
/**
* Findet Entity und lädt sie sofort (eager loading)
*/
public function findEager(string $entityClass, mixed $id): ?object
{
// Prüfe Identity Map zuerst
if ($this->identityMap->has($entityClass, $id)) {
$entity = $this->identityMap->get($entityClass, $id);
// Falls es ein Ghost ist, initialisiere es
if ($this->isLazyGhost($entity)) {
$this->initializeLazyObject($entity);
}
return $entity;
}
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
$data = $result->fetch();
if (! $data) {
return null;
}
$entity = $this->hydrator->hydrate($metadata, $data);
// In Identity Map speichern
$this->identityMap->set($entityClass, $id, $entity);
// Entity Loaded Event dispatchen
$this->entityEventManager->entityLoaded($entity, $entityClass, $id, $data, false);
return $entity;
}
/**
* Interne Methode für Lazy Loading
*/
private function findWithLazyLoading(string $entityClass, mixed $id): ?object
{
// Prüfe ob Entity existiert (schneller Check)
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT {$metadata->idColumn} FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
if (! $result->fetch()) {
return null;
}
// Erstelle Lazy Ghost
$entity = $this->lazyLoader->createLazyGhost($metadata, $id);
// Entity Loaded Event dispatchen (als Lazy)
$this->entityEventManager->entityLoaded($entity, $entityClass, $id, [], true);
return $entity;
}
/**
* Referenz auf Entity (ohne Existenz-Check)
*/
public function getReference(string $entityClass, mixed $id): object
{
// Prüfe Identity Map
if ($this->identityMap->has($entityClass, $id)) {
return $this->identityMap->get($entityClass, $id);
}
$metadata = $this->metadataRegistry->getMetadata($entityClass);
return $this->lazyLoader->createLazyGhost($metadata, $id);
}
/**
* Findet alle Entities - standardmäßig lazy
*/
public function findAll(string $entityClass): array
{
return $this->findAllLazy($entityClass);
}
/**
* Findet alle Entities und lädt sie sofort
*/
public function findAllEager(string $entityClass): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT * FROM {$metadata->tableName}";
$result = $this->databaseManager->getConnection()->query($query);
$entities = [];
foreach ($result->fetchAll() as $data) {
// Prüfe Identity Map für jede Entity
$idValue = $data[$metadata->idColumn];
if ($this->identityMap->has($entityClass, $idValue)) {
$entity = $this->identityMap->get($entityClass, $idValue);
// Falls Ghost, initialisiere es für eager loading
if ($this->isLazyGhost($entity)) {
$this->initializeLazyObject($entity);
}
$entities[] = $entity;
} else {
$entity = $this->hydrator->hydrate($metadata, $data);
$this->identityMap->set($entityClass, $idValue, $entity);
$entities[] = $entity;
}
}
return $entities;
}
/**
* Interne Methode für lazy findAll
*/
private function findAllLazy(string $entityClass): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
// Nur IDs laden
$query = "SELECT {$metadata->idColumn} FROM {$metadata->tableName}";
$result = $this->databaseManager->getConnection()->query($query);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
if ($this->identityMap->has($entityClass, $idValue)) {
$entities[] = $this->identityMap->get($entityClass, $idValue);
} else {
$entities[] = $this->lazyLoader->createLazyGhost($metadata, $idValue);
}
}
return $entities;
}
/**
* Findet Entities nach Kriterien
*/
public function findBy(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
// Use cache manager if available
if ($this->cacheManager !== null) {
return $this->cacheManager->findCollection($entityClass, $criteria, $orderBy, $limit, null, function () use ($entityClass, $criteria, $orderBy, $limit) {
return $this->findByWithoutCache($entityClass, $criteria, $orderBy, $limit);
});
}
return $this->findByWithoutCache($entityClass, $criteria, $orderBy, $limit);
}
/**
* Internal method for finding entities without cache
*/
private function findByWithoutCache(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT {$metadata->idColumn} FROM {$metadata->tableName}";
$params = [];
if (! empty($criteria)) {
$conditions = [];
foreach ($criteria as $field => $value) {
$columnName = $metadata->getColumnName($field);
$conditions[] = "{$columnName} = ?";
$params[] = $value;
}
$query .= " WHERE " . implode(' AND ', $conditions);
}
if ($orderBy) {
$orderClauses = [];
foreach ($orderBy as $field => $direction) {
$columnName = $metadata->getColumnName($field);
$orderClauses[] = "{$columnName} " . strtoupper($direction);
}
$query .= " ORDER BY " . implode(', ', $orderClauses);
}
if ($limit) {
$query .= " LIMIT {$limit}";
}
$result = $this->databaseManager->getConnection()->query($query, $params);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
$entities[] = $this->find($entityClass, $idValue); // Nutzt lazy loading
}
return $entities;
}
/**
* Findet eine Entity nach Kriterien
*/
public function findOneBy(string $entityClass, array $criteria): ?object
{
$results = $this->findBy($entityClass, $criteria, limit: 1);
return $results[0] ?? null;
}
/**
* Findet Entities mit vorab geladenen Relationen (N+1 Solution)
*
* @param string $entityClass
* @param array $criteria
* @param array $relations Relations to preload ['comments', 'author', etc.]
* @param array|null $orderBy
* @param int|null $limit
* @return array<object>
*/
public function findWithRelations(
string $entityClass,
array $criteria = [],
array $relations = [],
?array $orderBy = null,
?int $limit = null
): array {
// Step 1: Load base entities (using existing findBy logic but eager)
$entities = $this->findByEager($entityClass, $criteria, $orderBy, $limit);
if (empty($entities) || empty($relations)) {
return $entities;
}
// Step 2: Preload each specified relation in batch
foreach ($relations as $relationName) {
$this->batchRelationLoader->preloadRelation($entities, $relationName);
}
return $entities;
}
/**
* Eager version of findBy - loads full entities immediately
* Used internally by findWithRelations
*/
private function findByEager(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): 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);
}
if ($orderBy) {
$orderClauses = [];
foreach ($orderBy as $field => $direction) {
$columnName = $metadata->getColumnName($field);
$orderClauses[] = "{$columnName} " . strtoupper($direction);
}
$query .= " ORDER BY " . implode(', ', $orderClauses);
}
if ($limit) {
$query .= " LIMIT {$limit}";
}
$result = $this->databaseManager->getConnection()->query($query, $params);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
// Check identity map first
if ($this->identityMap->has($entityClass, $idValue)) {
$entity = $this->identityMap->get($entityClass, $idValue);
// If it's a lazy ghost, initialize it for eager loading
if ($this->isLazyGhost($entity)) {
$this->initializeLazyObject($entity);
}
$entities[] = $entity;
} else {
$entity = $this->hydrator->hydrate($metadata, $data);
$this->identityMap->set($entityClass, $idValue, $entity);
$entities[] = $entity;
}
}
return $entities;
}
/**
* Utility Methods
*/
public function detach(object $entity): void
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// ID der Entity ermitteln
$constructor = $metadata->reflection->getConstructor();
if ($constructor) {
foreach ($constructor->getParameters() as $param) {
$paramName = $param->getName();
$propertyMetadata = $metadata->getProperty($paramName);
if ($propertyMetadata && $propertyMetadata->columnName === $metadata->idColumn) {
try {
$property = $metadata->reflection->getProperty($paramName);
$id = $property->getValue($entity);
// Entity Detached Event dispatchen (vor Identity Map Remove)
$this->entityEventManager->entityDetached($entity, $entity::class, $id);
$this->identityMap->remove($entity::class, $id);
break;
} catch (\ReflectionException) {
// Property nicht gefunden
}
}
}
}
}
public function clear(): void
{
$this->identityMap->clear();
}
public function getIdentityMapStats(): array
{
return $this->identityMap->getStats();
}
public function isLazyGhost(object $entity): bool
{
return $this->lazyLoader->isLazyGhost($entity);
}
public function initializeLazyObject(object $entity): void
{
$this->lazyLoader->initializeLazyObject($entity);
}
public function getMetadata(string $entityClass): EntityMetadata
{
return $this->metadataRegistry->getMetadata($entityClass);
}
/**
* Generiert eine einzigartige ID für Entities
*/
public function generateId(): string
{
return IdGenerator::generate();
}
/**
* Get profiling statistics if profiling is enabled
*/
public function getProfilingStatistics(): ?array
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->getProfilingStatistics();
}
return null;
}
/**
* Get profiling summary if profiling is enabled
*/
public function getProfilingSummary(): ?\App\Framework\Database\Profiling\ProfileSummary
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->getProfilingSummary();
}
return null;
}
/**
* Clear profiling data if profiling is enabled
*/
public function clearProfilingData(): void
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
$connection->clearProfilingData();
}
}
/**
* Enable or disable profiling at runtime
*/
public function setProfilingEnabled(bool $enabled): void
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
$connection->setProfilingEnabled($enabled);
}
}
/**
* Check if profiling is enabled
*/
public function isProfilingEnabled(): bool
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->isProfilingEnabled();
}
return false;
}
/**
* Speichert eine Entity (INSERT oder UPDATE)
*/
public function save(object $entity): object
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
// Prüfe ob Entity bereits existiert
if ($this->exists($entity::class, $id)) {
$result = $this->update($entity);
} else {
$result = $this->insert($entity);
}
// Cache the entity after successful save
if ($this->cacheManager !== null) {
$this->cacheManager->cacheEntity($result);
}
return $result;
}
/**
* Prüft ob eine Entity mit der angegebenen ID existiert
*/
public function exists(string $entityClass, mixed $id): bool
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT 1 FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ? LIMIT 1";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
return (bool) $result->fetch();
}
/**
* Fügt eine neue Entity ein (INSERT)
*/
public function insert(object $entity): object
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// Columnnamen und Values sammeln
$columns = [];
$values = [];
$params = [];
foreach ($metadata->properties as $propertyName => $propertyMetadata) {
// ID Property überspringen wenn auto-increment
if ($propertyName === $metadata->idProperty && $propertyMetadata->autoIncrement) {
continue;
}
// Property-Wert auslesen
$property = $metadata->reflection->getProperty($propertyName);
if (! $property->isInitialized($entity)) {
continue;
}
$columns[] = $propertyMetadata->columnName;
$values[] = '?';
$params[] = $property->getValue($entity);
}
// INSERT Query bauen
$query = "INSERT INTO {$metadata->tableName} (" . implode(', ', $columns) . ") "
. "VALUES (" . implode(', ', $values) . ")";
// Query ausführen
$result = $this->databaseManager->getConnection()->execute($query, $params);
// In Identity Map speichern
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
$this->identityMap->set($entity::class, $id, $entity);
// Entity Created Event dispatchen
$this->entityEventManager->entityCreated($entity, $entity::class, $id, $params);
return $entity;
}
/**
* Aktualisiert eine vorhandene Entity (UPDATE)
*/
public function update(object $entity): object
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// ID für Change Tracking und WHERE Clause
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
// Change Tracking: Original Entity aus IdentityMap laden
$originalEntity = $this->identityMap->get($entity::class, $id);
$changes = [];
$oldValues = [];
$newValues = [];
// SET-Clause und Params aufbauen
$setClause = [];
$params = [];
foreach ($metadata->properties as $propertyName => $propertyMetadata) {
// Relations beim Update ignorieren
if ($propertyMetadata->isRelation) {
continue;
}
// ID überspringen (wird nicht aktualisiert)
if ($propertyName === $metadata->idProperty) {
continue;
}
// Property-Wert auslesen
$property = $metadata->reflection->getProperty($propertyName);
$newValue = $property->getValue($entity);
// Change Tracking: Vergleiche mit Original-Werten
$oldValue = null;
if ($originalEntity !== null) {
$originalProperty = $metadata->reflection->getProperty($propertyName);
$oldValue = $originalProperty->getValue($originalEntity);
}
// Nur bei Änderungen in SET clause aufnehmen und Changes tracken
if ($originalEntity === null || $oldValue !== $newValue) {
$setClause[] = "{$propertyMetadata->columnName} = ?";
$params[] = $newValue;
// Change Tracking Daten sammeln
$changes[] = $propertyName;
$oldValues[$propertyName] = $oldValue;
$newValues[$propertyName] = $newValue;
}
}
// Wenn keine Änderungen vorliegen, kein UPDATE ausführen
if (empty($setClause)) {
return $entity;
}
// ID für WHERE Clause hinzufügen
$params[] = $id;
// UPDATE Query bauen
$query = "UPDATE {$metadata->tableName} SET " . implode(', ', $setClause)
. " WHERE {$metadata->idColumn} = ?";
// Query ausführen
$result = $this->databaseManager->getConnection()->query($query, $params);
// Identity Map aktualisieren
$this->identityMap->set($entity::class, $id, $entity);
// Entity Updated Event mit Change Tracking dispatchen
$this->entityEventManager->entityUpdated($entity, $entity::class, $id, $changes, $oldValues, $newValues);
return $entity;
}
/**
* Löscht eine Entity
*/
public function delete(object $entity): void
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// ID auslesen
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
// DELETE Query ausführen
$query = "DELETE FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$this->databaseManager->getConnection()->query($query, [$id]);
// Evict from cache
if ($this->cacheManager !== null) {
$this->cacheManager->evictEntity($entity);
}
// Entity Deleted Event dispatchen (vor Identity Map Remove)
$this->entityEventManager->entityDeleted($entity, $entity::class, $id, []);
// Aus Identity Map entfernen
$this->identityMap->remove($entity::class, $id);
}
/**
* Speichert mehrere Entities auf einmal
*/
public function saveAll(object ...$entities): array
{
$result = [];
foreach ($entities as $entity) {
$result[] = $this->save($entity);
}
return $result;
}
/**
* Führt eine Funktion in einer Transaktion aus
*/
public function transaction(callable $callback): mixed
{
$connection = $this->databaseManager->getConnection();
// Wenn bereits in einer Transaktion, führe Callback direkt aus
if ($connection->inTransaction()) {
return $callback($this);
}
// Neue Transaktion starten
$connection->beginTransaction();
try {
$result = $callback($this);
$connection->commit();
return $result;
} catch (\Throwable $e) {
$connection->rollback();
throw $e;
}
}
/**
* Execute criteria query and return entities
*/
public function findByCriteria(Criteria $criteria): array
{
$metadata = $this->metadataRegistry->getMetadata($criteria->entityClass);
$criteriaQuery = new CriteriaQuery($criteria, $metadata->tableName);
$sql = $criteriaQuery->toSql();
$parameters = $criteriaQuery->getParameters();
$result = $this->databaseManager->getConnection()->query($sql, $parameters);
$entities = [];
foreach ($result->fetchAll() as $data) {
// Check if it's a projection query (non-entity result)
$projection = $criteria->getProjection();
if ($projection && count($projection->getAliases()) > 0) {
// Return raw data for projection queries
$entities[] = $data;
} else {
// Normal entity hydration
$idValue = $data[$metadata->idColumn];
if ($this->identityMap->has($criteria->entityClass, $idValue)) {
$entities[] = $this->identityMap->get($criteria->entityClass, $idValue);
} else {
$entity = $this->hydrator->hydrate($metadata, $data);
$this->identityMap->set($criteria->entityClass, $idValue, $entity);
$entities[] = $entity;
}
}
}
return $entities;
}
/**
* Execute criteria query and return first result
*/
public function findOneByCriteria(Criteria $criteria): mixed
{
$criteria->setMaxResults(1);
$results = $this->findByCriteria($criteria);
return $results[0] ?? null;
}
/**
* Count entities matching criteria
*/
public function countByCriteria(Criteria $criteria): int
{
$metadata = $this->metadataRegistry->getMetadata($criteria->entityClass);
// Create count criteria
$countCriteria = clone $criteria;
$countCriteria->setProjection(\App\Framework\Database\Criteria\Projections::count());
$countCriteria->setMaxResults(null);
$countCriteria->setFirstResult(null);
$criteriaQuery = new CriteriaQuery($countCriteria, $metadata->tableName);
$sql = $criteriaQuery->toSql();
$parameters = $criteriaQuery->getParameters();
$result = $this->databaseManager->getConnection()->queryScalar($sql, $parameters);
return (int) $result;
}
/**
* Create a new query builder
*/
public function createQueryBuilder(): SelectQueryBuilder
{
return $this->queryBuilderFactory->select();
}
/**
* Create a query builder for a specific entity
*/
public function createQueryBuilderFor(string $entityClass): SelectQueryBuilder
{
return $this->queryBuilderFactory->selectFromWithEntity($entityClass, $this->identityMap, $this->hydrator);
}
/**
* Create a query builder for a table
*/
public function createQueryBuilderForTable(string $tableName): SelectQueryBuilder
{
return $this->queryBuilderFactory->selectFromTable($tableName);
}
/**
* Get the identity map (for QueryBuilder integration)
*/
public function getIdentityMap(): IdentityMap
{
return $this->identityMap;
}
/**
* Get the hydrator (for QueryBuilder integration)
*/
public function getHydrator(): HydratorInterface
{
return $this->hydrator;
}
/**
* Get the entity event manager
*/
public function getEntityEventManager(): EntityEventManager
{
return $this->entityEventManager;
}
/**
* Record a domain event for an entity
*/
public function recordDomainEvent(object $entity, object $event): void
{
$this->entityEventManager->recordDomainEvent($entity, $event);
}
/**
* Dispatch all domain events for an entity
*/
public function dispatchDomainEventsForEntity(object $entity): void
{
$this->entityEventManager->dispatchDomainEventsForEntity($entity);
}
/**
* Dispatch all domain events across all entities
*/
public function dispatchAllDomainEvents(): void
{
$this->entityEventManager->dispatchAllDomainEvents();
}
/**
* Get domain event statistics
*/
public function getDomainEventStats(): array
{
return $this->entityEventManager->getDomainEventStats();
}
}