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
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Database;
use App\Framework\Async\AsyncService;
use App\Framework\Database\Contracts\AsyncCapable;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Decorator der jede ConnectionInterface-Implementierung um async Property erweitert
@@ -25,29 +26,29 @@ final readonly class AsyncAwareConnection implements ConnectionInterface, AsyncC
// === STANDARD CONNECTION INTERFACE (Delegation) ===
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
return $this->connection->execute($sql, $parameters);
return $this->connection->execute($query);
}
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
return $this->connection->query($sql, $parameters);
return $this->connection->query($query);
}
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
return $this->connection->queryOne($sql, $parameters);
return $this->connection->queryOne($query);
}
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
return $this->connection->queryColumn($sql, $parameters);
return $this->connection->queryColumn($query);
}
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
return $this->connection->queryScalar($sql, $parameters);
return $this->connection->queryScalar($query);
}
public function beginTransaction(): void

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Database;
use App\Framework\Async\AsyncPromise;
use App\Framework\Async\AsyncService;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Async Decorator für Database ConnectionInterface
@@ -29,29 +30,29 @@ final readonly class AsyncDatabaseDecorator implements ConnectionInterface
// === STANDARD SYNCHRONOUS INTERFACE ===
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
return $this->connection->execute($sql, $parameters);
return $this->connection->execute($query);
}
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
return $this->connection->query($sql, $parameters);
return $this->connection->query($query);
}
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
return $this->connection->queryOne($sql, $parameters);
return $this->connection->queryOne($query);
}
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
return $this->connection->queryColumn($sql, $parameters);
return $this->connection->queryColumn($query);
}
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
return $this->connection->queryScalar($sql, $parameters);
return $this->connection->queryScalar($query);
}
public function beginTransaction(): void

View File

@@ -8,6 +8,7 @@ use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Database\Backup\BackupOptions;
use App\Framework\Database\Backup\BackupRetentionPolicy;
@@ -25,7 +26,7 @@ final readonly class BackupCommand
}
#[ConsoleCommand('backup:create', 'Create a database backup')]
public function create(ConsoleInput $input, ConsoleOutput $output): int
public function create(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine('🗄️ Creating database backup...', ConsoleColor::CYAN);
$output->newLine();
@@ -59,21 +60,21 @@ final readonly class BackupCommand
$output->writeLine("• Checksum: {$result->metadata->checksum}");
}
return 0;
return ExitCode::SUCCESS;
} else {
$output->writeError('❌ Backup failed: ' . $result->message);
return 1;
return ExitCode::GENERAL_ERROR;
}
} catch (\Throwable $e) {
$output->writeError('❌ Backup failed: ' . $e->getMessage());
return 1;
return ExitCode::GENERAL_ERROR;
}
}
#[ConsoleCommand('backup:restore', 'Restore database from backup')]
public function restore(ConsoleInput $input, ConsoleOutput $output): int
public function restore(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$backupFile = $input->getArgument(0);
@@ -83,7 +84,7 @@ final readonly class BackupCommand
if (empty($backups)) {
$output->writeError('❌ No backups found');
return 1;
return ExitCode::GENERAL_ERROR;
}
$output->writeLine('📋 Available backups:', ConsoleColor::CYAN);
@@ -103,7 +104,7 @@ final readonly class BackupCommand
if (! isset($backups[$index])) {
$output->writeError('❌ Invalid backup selection');
return 1;
return ExitCode::GENERAL_ERROR;
}
$backupFile = $backups[$index]['path'];
@@ -113,7 +114,7 @@ final readonly class BackupCommand
if (! $backupPath->exists()) {
$output->writeError("❌ Backup file not found: {$backupFile}");
return 1;
return ExitCode::GENERAL_ERROR;
}
// Confirm restore
@@ -121,7 +122,7 @@ final readonly class BackupCommand
if (! $output->confirm('Are you sure you want to restore?', false)) {
$output->writeInfo('Restore cancelled');
return 0;
return ExitCode::SUCCESS;
}
try {
@@ -132,21 +133,21 @@ final readonly class BackupCommand
if ($result->success) {
$output->writeSuccess('✅ Database restored successfully!');
return 0;
return ExitCode::SUCCESS;
} else {
$output->writeError('❌ Restore failed: ' . $result->message);
return 1;
return ExitCode::GENERAL_ERROR;
}
} catch (\Throwable $e) {
$output->writeError('❌ Restore failed: ' . $e->getMessage());
return 1;
return ExitCode::GENERAL_ERROR;
}
}
#[ConsoleCommand('backup:list', 'List all available backups')]
public function list(ConsoleInput $input, ConsoleOutput $output): int
public function list(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
try {
$backups = $this->backupService->listBackups();
@@ -154,7 +155,7 @@ final readonly class BackupCommand
if (empty($backups)) {
$output->writeInfo(' No backups found');
return 0;
return ExitCode::SUCCESS;
}
$output->writeLine('📋 Available backups:', ConsoleColor::CYAN);
@@ -178,16 +179,16 @@ final readonly class BackupCommand
$output->writeLine("Total backups: " . count($backups), ConsoleColor::CYAN);
return 0;
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to list backups: ' . $e->getMessage());
return 1;
return ExitCode::GENERAL_ERROR;
}
}
#[ConsoleCommand('backup:cleanup', 'Clean up old backups based on retention policy')]
public function cleanup(ConsoleInput $input, ConsoleOutput $output): int
public function cleanup(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
try {
// Parse retention policy
@@ -211,30 +212,30 @@ final readonly class BackupCommand
$output->writeInfo(' No backups needed cleanup');
}
return 0;
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('❌ Cleanup failed: ' . $e->getMessage());
return 1;
return ExitCode::GENERAL_ERROR;
}
}
#[ConsoleCommand('backup:verify', 'Verify backup integrity')]
public function verify(ConsoleInput $input, ConsoleOutput $output): int
public function verify(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$backupFile = $input->getArgument(0);
if (! $backupFile) {
$output->writeError('❌ Please specify a backup file to verify');
return 1;
return ExitCode::GENERAL_ERROR;
}
$backupPath = FilePath::create($backupFile);
if (! $backupPath->exists()) {
$output->writeError("❌ Backup file not found: {$backupFile}");
return 1;
return ExitCode::GENERAL_ERROR;
}
try {
@@ -258,11 +259,11 @@ final readonly class BackupCommand
$output->writeLine("• Size: {$metadata->getSummary()}");
$output->writeLine("• Created: {$metadata->createdAt}");
return 0;
return ExitCode::SUCCESS;
} else {
$output->writeError('❌ Backup integrity check failed - checksum mismatch');
return 1;
return ExitCode::GENERAL_ERROR;
}
} else {
$output->writeWarning('⚠️ Could not parse metadata file');
@@ -276,16 +277,16 @@ final readonly class BackupCommand
if ($size->isNotEmpty()) {
$output->writeInfo(" File exists and has size: {$size->toHumanReadable()}");
return 0;
return ExitCode::SUCCESS;
} else {
$output->writeError('❌ Backup file is empty');
return 1;
return ExitCode::GENERAL_ERROR;
}
} catch (\Throwable $e) {
$output->writeError('❌ Verification failed: ' . $e->getMessage());
return 1;
return ExitCode::GENERAL_ERROR;
}
}

View File

@@ -14,7 +14,8 @@ final readonly class BatchRelationLoader
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private IdentityMap $identityMap,
private HydratorInterface $hydrator
private HydratorInterface $hydrator,
private TypeResolver $typeResolver
) {
}
@@ -75,7 +76,8 @@ final readonly class BatchRelationLoader
// Set relations on entities
foreach ($entities as $entity) {
$localKey = $this->getLocalKey($entity, $relationMetadata);
$relations = $groupedRelations[$localKey] ?? [];
$keyString = $this->convertKeyForArray($localKey);
$relations = $groupedRelations[$keyString] ?? [];
$this->setRelationOnEntity($entity, $relationMetadata->name, $relations);
}
}
@@ -109,7 +111,8 @@ final readonly class BatchRelationLoader
// Set relations on entities
foreach ($entities as $entity) {
$foreignKey = $this->getForeignKey($entity, $relationMetadata);
$relation = $indexedRelations[$foreignKey] ?? null;
$keyString = $this->convertKeyForArray($foreignKey);
$relation = $indexedRelations[$keyString] ?? null;
$this->setRelationOnEntity($entity, $relationMetadata->name, $relation);
}
}
@@ -140,7 +143,8 @@ final readonly class BatchRelationLoader
foreach ($entities as $entity) {
$localKey = $this->getLocalKey($entity, $relationMetadata);
$relation = $groupedRelations[$localKey][0] ?? null; // Take first (should be only one)
$keyString = $this->convertKeyForArray($localKey);
$relation = $groupedRelations[$keyString][0] ?? null; // Take first (should be only one)
$this->setRelationOnEntity($entity, $relationMetadata->name, $relation);
}
}
@@ -205,7 +209,8 @@ final readonly class BatchRelationLoader
foreach ($entities as $entity) {
$key = $this->getPropertyValue($entity, $foreignKeyProperty);
if ($key !== null) {
$grouped[$key][] = $entity;
$keyString = $this->convertKeyForArray($key);
$grouped[$keyString][] = $entity;
}
}
@@ -223,7 +228,8 @@ final readonly class BatchRelationLoader
foreach ($entities as $entity) {
$key = $this->getPropertyValue($entity, 'id'); // Assuming 'id' is primary key
if ($key !== null) {
$indexed[$key] = $entity;
$keyString = $this->convertKeyForArray($key);
$indexed[$keyString] = $entity;
}
}
@@ -261,7 +267,8 @@ final readonly class BatchRelationLoader
$query .= " WHERE " . implode(' AND ', $conditions);
}
$result = $this->databaseManager->getConnection()->query($query, $params);
$sqlQuery = ValueObjects\SqlQuery::create($query, $params);
$result = $this->databaseManager->getConnection()->query($sqlQuery);
$entities = [];
foreach ($result->fetchAll() as $data) {
@@ -302,4 +309,13 @@ final readonly class BatchRelationLoader
}
}
}
/**
* 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);
}
}

View File

@@ -4,17 +4,19 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\ValueObjects\SqlQuery;
interface ConnectionInterface
{
public function execute(string $sql, array $parameters = []): int;
public function execute(SqlQuery $query): int;
public function query(string $sql, array $parameters = []): ResultInterface;
public function query(SqlQuery $query): ResultInterface;
public function queryOne(string $sql, array $parameters = []): ?array;
public function queryOne(SqlQuery $query): ?array;
public function queryColumn(string $sql, array $parameters = []): array;
public function queryColumn(SqlQuery $query): array;
public function queryScalar(string $sql, array $parameters = []): mixed;
public function queryScalar(SqlQuery $query): mixed;
public function beginTransaction(): void;

View File

@@ -9,6 +9,7 @@ use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\Migration\MigrationLoader;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\Profiling\ProfileSummary;
use App\Framework\Database\Profiling\ProfilingConnection;
use App\Framework\Database\Profiling\ProfilingDashboard;
@@ -17,6 +18,7 @@ use App\Framework\Database\Profiling\QueryProfiler;
use App\Framework\Database\Profiling\SlowQueryDetector;
use App\Framework\Database\ReadWrite\ReplicationLagDetector;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
use App\Framework\DateTime\Timer;
use App\Framework\Logging\Logger;
@@ -28,6 +30,7 @@ final class DatabaseManager
public function __construct(
private readonly DatabaseConfig $config,
private readonly DatabasePlatform $platform,
private readonly Timer $timer,
private readonly string $migrationsPath = 'database/migrations',
private readonly ?Clock $clock = null,
@@ -156,7 +159,13 @@ final class DatabaseManager
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
$runner = new MigrationRunner(
connection: $this->getConnection(),
platform: $this->platform,
clock: $this->clock ?? new SystemClock(),
tableConfig: null,
logger: $this->logger
);
return $runner->migrate($migrations);
}
@@ -168,7 +177,13 @@ final class DatabaseManager
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
$runner = new MigrationRunner(
connection: $this->getConnection(),
platform: $this->platform,
clock: $this->clock ?? new SystemClock(),
tableConfig: null,
logger: $this->logger
);
return $runner->rollback($migrations, $steps);
}
@@ -180,7 +195,13 @@ final class DatabaseManager
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
$runner = new MigrationRunner(
connection: $this->getConnection(),
platform: $this->platform,
clock: $this->clock ?? new SystemClock(),
tableConfig: null,
logger: $this->logger
);
return $runner->getStatus($migrations);
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Criteria\Criteria;
interface EntityLoaderInterface
{
/**
@@ -12,11 +14,23 @@ interface EntityLoaderInterface
public function find(string $entityClass, mixed $id): ?object;
/**
* Find single entity by criteria using Value Objects
*/
public function findOneByCriteria(Criteria $criteria): ?object;
/**
* Find multiple entities by criteria using Value Objects
*/
public function findByCriteria(Criteria $criteria): array;
/**
* @deprecated Use findOneByCriteria() with DetachedCriteria instead
* Find single entity by criteria
*/
public function findOneBy(string $entityClass, array $criteria): ?object;
/**
* @deprecated Use findByCriteria() with DetachedCriteria instead
* Find multiple entities by criteria
*/
public function findBy(string $entityClass, array $criteria): array;

View File

@@ -7,17 +7,29 @@ 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\Services\EntityFinder;
use App\Framework\Database\Services\EntityPersister;
use App\Framework\Database\Services\EntityQueryManager;
use App\Framework\Database\Services\EntityUtilities;
use App\Framework\Database\TypeCaster\TypeCasterRegistry;
use App\Framework\Database\UnitOfWork\UnitOfWork;
#[Singleton]
final readonly class EntityManager implements EntityLoaderInterface
{
private EntityFinder $finder;
private EntityPersister $persister;
private EntityQueryManager $queryManager;
private EntityUtilities $utilities;
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
@@ -29,8 +41,43 @@ final readonly class EntityManager implements EntityLoaderInterface
public UnitOfWork $unitOfWork,
private QueryBuilderFactory $queryBuilderFactory,
private EntityEventManager $entityEventManager,
private TypeCasterRegistry $typeCasterRegistry,
private ?EntityCacheManager $cacheManager = null
) {
// Initialize service classes
$this->finder = new EntityFinder(
$this->databaseManager,
$this->metadataRegistry,
$this->identityMap,
$this->lazyLoader,
$this->hydrator,
$this->batchRelationLoader,
$this->entityEventManager,
$this->cacheManager
);
$this->persister = new EntityPersister(
$this->databaseManager,
$this->metadataRegistry,
$this->identityMap,
$this->entityEventManager,
$this->typeCasterRegistry,
$this->cacheManager
);
$this->queryManager = new EntityQueryManager(
$this->databaseManager,
$this->queryBuilderFactory,
$this->identityMap,
$this->hydrator
);
$this->utilities = new EntityUtilities(
$this->databaseManager,
$this->metadataRegistry,
$this->identityMap,
$this->entityEventManager
);
}
/**
@@ -38,20 +85,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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);
return $this->finder->find($entityClass, $id);
}
/**
@@ -59,61 +93,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->finder->findEager($entityClass, $id);
}
/**
@@ -121,14 +101,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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);
return $this->finder->getReference($entityClass, $id);
}
/**
@@ -136,7 +109,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function findAll(string $entityClass): array
{
return $this->findAllLazy($entityClass);
return $this->finder->findAll($entityClass);
}
/**
@@ -144,127 +117,25 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->finder->findAllEager($entityClass);
}
/**
* @deprecated Use findByCriteria() with DetachedCriteria instead for better type safety
* 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;
return $this->finder->findBy($entityClass, $criteria, $orderBy, $limit);
}
/**
* @deprecated Use findOneByCriteria() with DetachedCriteria instead for better type safety
* Findet eine Entity nach Kriterien
*/
public function findOneBy(string $entityClass, array $criteria): ?object
{
$results = $this->findBy($entityClass, $criteria, limit: 1);
return $results[0] ?? null;
return $this->finder->findOneBy($entityClass, $criteria);
}
/**
@@ -284,87 +155,7 @@ final readonly class EntityManager implements EntityLoaderInterface
?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;
return $this->finder->findWithRelations($entityClass, $criteria, $relations, $orderBy, $limit);
}
/**
@@ -372,57 +163,32 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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
}
}
}
}
$this->utilities->detach($entity);
}
public function clear(): void
{
$this->identityMap->clear();
$this->utilities->clear();
}
public function getIdentityMapStats(): array
{
return $this->identityMap->getStats();
return $this->utilities->getIdentityMapStats();
}
public function isLazyGhost(object $entity): bool
{
return $this->lazyLoader->isLazyGhost($entity);
return $this->finder->isLazyGhost($entity);
}
public function initializeLazyObject(object $entity): void
{
$this->lazyLoader->initializeLazyObject($entity);
$this->finder->initializeLazyObject($entity);
}
public function getMetadata(string $entityClass): EntityMetadata
{
return $this->metadataRegistry->getMetadata($entityClass);
return $this->utilities->getMetadata($entityClass);
}
/**
@@ -430,7 +196,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function generateId(): string
{
return IdGenerator::generate();
return $this->utilities->generateId();
}
/**
@@ -438,13 +204,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function getProfilingStatistics(): ?array
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->getProfilingStatistics();
}
return null;
return $this->utilities->getProfilingStatistics();
}
/**
@@ -452,13 +212,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->utilities->getProfilingSummary();
}
/**
@@ -466,11 +220,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function clearProfilingData(): void
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
$connection->clearProfilingData();
}
$this->utilities->clearProfilingData();
}
/**
@@ -478,11 +228,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function setProfilingEnabled(bool $enabled): void
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
$connection->setProfilingEnabled($enabled);
}
$this->utilities->setProfilingEnabled($enabled);
}
/**
@@ -490,13 +236,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function isProfilingEnabled(): bool
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->isProfilingEnabled();
}
return false;
return $this->utilities->isProfilingEnabled();
}
/**
@@ -504,24 +244,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->persister->save($entity);
}
/**
@@ -529,12 +252,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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();
return $this->finder->exists($entityClass, $id);
}
/**
@@ -542,51 +260,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->persister->insert($entity);
}
/**
@@ -594,79 +268,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->persister->update($entity);
}
/**
@@ -674,26 +276,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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);
$this->persister->delete($entity);
}
/**
@@ -701,12 +284,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function saveAll(object ...$entities): array
{
$result = [];
foreach ($entities as $entity) {
$result[] = $this->save($entity);
}
return $result;
return $this->persister->saveAll(...$entities);
}
/**
@@ -714,26 +292,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
}
return $this->queryManager->transaction(fn () => $callback($this));
}
/**
@@ -741,47 +300,15 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->finder->findByCriteria($criteria);
}
/**
* Execute criteria query and return first result
*/
public function findOneByCriteria(Criteria $criteria): mixed
public function findOneByCriteria(Criteria $criteria): ?object
{
$criteria->setMaxResults(1);
$results = $this->findByCriteria($criteria);
return $results[0] ?? null;
return $this->finder->findOneByCriteria($criteria);
}
/**
@@ -789,22 +316,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
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;
return $this->finder->countByCriteria($criteria);
}
/**
@@ -812,7 +324,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function createQueryBuilder(): SelectQueryBuilder
{
return $this->queryBuilderFactory->select();
return $this->queryManager->createQueryBuilder();
}
/**
@@ -820,7 +332,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function createQueryBuilderFor(string $entityClass): SelectQueryBuilder
{
return $this->queryBuilderFactory->selectFromWithEntity($entityClass, $this->identityMap, $this->hydrator);
return $this->queryManager->createQueryBuilderFor($entityClass);
}
/**
@@ -828,7 +340,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function createQueryBuilderForTable(string $tableName): SelectQueryBuilder
{
return $this->queryBuilderFactory->selectFromTable($tableName);
return $this->queryManager->createQueryBuilderForTable($tableName);
}
/**
@@ -836,7 +348,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function getIdentityMap(): IdentityMap
{
return $this->identityMap;
return $this->utilities->getIdentityMap();
}
/**
@@ -852,7 +364,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function getEntityEventManager(): EntityEventManager
{
return $this->entityEventManager;
return $this->utilities->getEntityEventManager();
}
/**
@@ -860,7 +372,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function recordDomainEvent(object $entity, object $event): void
{
$this->entityEventManager->recordDomainEvent($entity, $event);
$this->utilities->recordDomainEvent($entity, $event);
}
/**
@@ -868,7 +380,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function dispatchDomainEventsForEntity(object $entity): void
{
$this->entityEventManager->dispatchDomainEventsForEntity($entity);
$this->utilities->dispatchDomainEventsForEntity($entity);
}
/**
@@ -876,7 +388,7 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function dispatchAllDomainEvents(): void
{
$this->entityEventManager->dispatchAllDomainEvents();
$this->utilities->dispatchAllDomainEvents();
}
/**
@@ -884,6 +396,6 @@ final readonly class EntityManager implements EntityLoaderInterface
*/
public function getDomainEventStats(): array
{
return $this->entityEventManager->getDomainEventStats();
return $this->utilities->getDomainEventStats();
}
}

View File

@@ -14,22 +14,26 @@ use App\Framework\Database\QueryBuilder\QueryBuilderFactory;
use App\Framework\Database\TypeCaster\TypeCasterRegistry;
use App\Framework\Database\UnitOfWork\UnitOfWorkFactory;
use App\Framework\DateTime\Clock;
use App\Framework\DI\Container;
use App\Framework\TypeCaster\TypeCasterRegistry as UniversalTypeCasterRegistry;
final class EntityManagerFactory
{
public static function create(DatabaseManager $databaseManager, EventDispatcher $eventDispatcher, Clock $clock, ?EntityCacheManager $cacheManager = null): EntityManager
public static function create(DatabaseManager $databaseManager, EventDispatcher $eventDispatcher, Clock $clock, Container $container, ?EntityCacheManager $cacheManager = null): EntityManager
{
$metadataExtractor = new MetadataExtractor();
$metadataRegistry = new MetadataRegistry($metadataExtractor);
$casterRegistry = new TypeCasterRegistry();
$universalCasterRegistry = $container->get(UniversalTypeCasterRegistry::class);
$casterRegistry = new TypeCasterRegistry($container, $universalCasterRegistry);
$typeConverter = new TypeConverter($casterRegistry);
$typeResolver = new TypeResolver($casterRegistry);
$identityMap = new IdentityMap();
// Create Hydrator without EntityLoader to break circular dependency
$hydrator = new Hydrator($typeConverter);
// Create BatchRelationLoader
$batchRelationLoader = new BatchRelationLoader($databaseManager, $metadataRegistry, $identityMap, $hydrator);
$batchRelationLoader = new BatchRelationLoader($databaseManager, $metadataRegistry, $identityMap, $hydrator, $typeResolver);
$lazyLoader = new LazyLoader(
databaseManager: $databaseManager,
@@ -72,6 +76,7 @@ final class EntityManagerFactory
unitOfWork: $unitOfWork,
queryBuilderFactory: $queryBuilderFactory,
entityEventManager: $entityEventManager,
typeCasterRegistry: $casterRegistry,
cacheManager: $cacheManager
);

View File

@@ -7,6 +7,8 @@ namespace App\Framework\Database;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Database\Cache\EntityCacheManager;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\Platform\MySQLPlatform;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\Timer;
use App\Framework\DI\Container;
@@ -29,8 +31,12 @@ final readonly class EntityManagerInitializer
$logger = $container->get(Logger::class);
}
// Create platform for the database (defaulting to MySQL)
$platform = new MySQLPlatform();
$db = new DatabaseManager(
$databaseConfig,
$platform,
$timer,
'database/migrations',
$clock,
@@ -52,6 +58,6 @@ final readonly class EntityManagerInitializer
$cacheManager = $container->get(EntityCacheManager::class);
}
return EntityManagerFactory::create($db, $eventDispatcher, $clock, $cacheManager);
return EntityManagerFactory::create($db, $eventDispatcher, $clock, $container, $cacheManager);
}
}

View File

@@ -7,18 +7,21 @@ namespace App\Framework\Database\Example;
use App\Framework\Database\Repository\EntityRepository;
/**
* Beispiel für Repository mit User-Entity
* Beispiel für Repository mit User-Entity (Komposition statt Vererbung)
*/
final class UserRepository extends EntityRepository
final readonly class UserRepository
{
protected string $entityClass = User::class;
public function __construct(
private EntityRepository $entityRepository
) {
}
/**
* Findet User nach Email
*/
public function findByEmail(string $email): ?User
{
return $this->findOneBy(['email' => $email]);
return $this->entityRepository->findOneBy(User::class, ['email' => $email]);
}
/**
@@ -28,7 +31,7 @@ final class UserRepository extends EntityRepository
{
$user = new User($name, $email);
return $this->save($user);
return $this->entityRepository->save($user);
}
/**
@@ -38,7 +41,7 @@ final class UserRepository extends EntityRepository
{
$updatedUser = $user->withName($newName);
return $this->save($updatedUser);
return $this->entityRepository->save($updatedUser);
}
/**
@@ -48,6 +51,6 @@ final class UserRepository extends EntityRepository
{
$updatedUser = $user->withEmail($newEmail);
return $this->save($updatedUser);
return $this->entityRepository->save($updatedUser);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Database;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\ValueObjects\SqlQuery;
use ReflectionClass;
final class LazyLoader
@@ -172,10 +173,13 @@ final class LazyLoader
$entityMetadata = $this->metadataRegistry->getMetadata($entity::class);
// Lade zuerst die Entity-Daten aus der DB, um den Foreign Key zu bekommen
$query = "SELECT * FROM {$entityMetadata->tableName} WHERE {$entityMetadata->idColumn} = ?";
$entityId = $this->getEntityId($entity, $entityMetadata);
$result = $this->databaseManager->getConnection()->query($query, [$entityId]);
$query = ValueObjects\SqlQuery::create(
"SELECT * FROM {$entityMetadata->tableName} WHERE {$entityMetadata->idColumn} = ?",
[$entityId]
);
$result = $this->databaseManager->getConnection()->query($query);
$data = $result->fetch();
if (! $data) {
@@ -198,7 +202,9 @@ final class LazyLoader
$query = "SELECT * FROM {$targetMetadata->tableName} WHERE {$targetMetadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$foreignKeyValue]);
$result = $this->databaseManager->getConnection()->query(
SqlQuery::create($query, [$foreignKeyValue])
);
$targetData = $result->fetch();
if (! $targetData) {
@@ -274,8 +280,11 @@ final class LazyLoader
{
$targetMetadata = $this->metadataRegistry->getMetadata($targetClass);
$query = "SELECT * FROM {$targetMetadata->tableName} WHERE {$foreignKey} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$localKeyValue]);
$query = ValueObjects\SqlQuery::create(
"SELECT * FROM {$targetMetadata->tableName} WHERE {$foreignKey} = ?",
[$localKeyValue]
);
$result = $this->databaseManager->getConnection()->query($query);
return $result->fetchAll();
}
@@ -339,8 +348,11 @@ final class LazyLoader
*/
private function loadEntity(EntityMetadata $metadata, mixed $id): object
{
$query = "SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
$query = ValueObjects\SqlQuery::create(
"SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?",
[$id]
);
$result = $this->databaseManager->getConnection()->query($query);
$data = $result->fetch();
if (! $data) {
@@ -356,8 +368,11 @@ final class LazyLoader
private function initializeGhost(object $ghost, EntityMetadata $metadata, mixed $id): void
{
// Daten aus Datenbank laden
$query = "SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
$query = ValueObjects\SqlQuery::create(
"SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?",
[$id]
);
$result = $this->databaseManager->getConnection()->query($query);
$data = $result->fetch();
if (! $data) {

View File

@@ -5,13 +5,13 @@ declare(strict_types=1);
namespace App\Framework\Database\Middleware;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
final class QueryContext
{
public function __construct(
public readonly string $operation,
public readonly string $sql,
public readonly array $parameters,
public readonly SqlQuery $query,
public readonly ConnectionInterface $connection,
public array $metadata = []
) {

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Database;
use App\Framework\Database\Middleware\MiddlewarePipeline;
use App\Framework\Database\Middleware\QueryContext;
use App\Framework\Database\ValueObjects\SqlQuery;
final class MiddlewareConnection implements ConnectionInterface
{
@@ -15,54 +16,55 @@ final class MiddlewareConnection implements ConnectionInterface
) {
}
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
$context = new QueryContext('execute', $sql, $parameters, $this->baseConnection);
$context = new QueryContext('execute', $query, $this->baseConnection);
return $this->pipeline->process($context, function (QueryContext $ctx): int {
return $ctx->connection->execute($ctx->sql, $ctx->parameters);
return $ctx->connection->execute($ctx->query);
});
}
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
$context = new QueryContext('query', $sql, $parameters, $this->baseConnection);
$context = new QueryContext('query', $query, $this->baseConnection);
return $this->pipeline->process($context, function (QueryContext $ctx): ResultInterface {
return $ctx->connection->query($ctx->sql, $ctx->parameters);
return $ctx->connection->query($ctx->query);
});
}
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
$context = new QueryContext('queryOne', $sql, $parameters, $this->baseConnection);
$context = new QueryContext('queryOne', $query, $this->baseConnection);
return $this->pipeline->process($context, function (QueryContext $ctx): ?array {
return $ctx->connection->queryOne($ctx->sql, $ctx->parameters);
return $ctx->connection->queryOne($ctx->query);
});
}
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
$context = new QueryContext('queryColumn', $sql, $parameters, $this->baseConnection);
$context = new QueryContext('queryColumn', $query, $this->baseConnection);
return $this->pipeline->process($context, function (QueryContext $ctx): array {
return $ctx->connection->queryColumn($ctx->sql, $ctx->parameters);
return $ctx->connection->queryColumn($ctx->query);
});
}
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
$context = new QueryContext('queryScalar', $sql, $parameters, $this->baseConnection);
$context = new QueryContext('queryScalar', $query, $this->baseConnection);
return $this->pipeline->process($context, function (QueryContext $ctx): mixed {
return $ctx->connection->queryScalar($ctx->sql, $ctx->parameters);
return $ctx->connection->queryScalar($ctx->query);
});
}
public function beginTransaction(): void
{
$context = new QueryContext('beginTransaction', '', [], $this->baseConnection);
$emptyQuery = SqlQuery::create('');
$context = new QueryContext('beginTransaction', $emptyQuery, $this->baseConnection);
$this->pipeline->process($context, function (QueryContext $ctx): void {
$ctx->connection->beginTransaction();
@@ -71,7 +73,8 @@ final class MiddlewareConnection implements ConnectionInterface
public function commit(): void
{
$context = new QueryContext('commit', '', [], $this->baseConnection);
$emptyQuery = SqlQuery::create('');
$context = new QueryContext('commit', $emptyQuery, $this->baseConnection);
$this->pipeline->process($context, function (QueryContext $ctx): void {
$ctx->connection->commit();
@@ -80,7 +83,8 @@ final class MiddlewareConnection implements ConnectionInterface
public function rollback(): void
{
$context = new QueryContext('rollback', '', [], $this->baseConnection);
$emptyQuery = SqlQuery::create('');
$context = new QueryContext('rollback', $emptyQuery, $this->baseConnection);
$this->pipeline->process($context, function (QueryContext $ctx): void {
$ctx->connection->rollback();

View File

@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Database\ConnectionInterface;
/**
* Abstract base class for migrations that have dependencies on other migrations.
*
* This class implements the DependentMigration interface and provides common
* functionality for dependency tracking. Concrete migrations can extend this
* class to easily add dependency support.
*/
abstract class AbstractDependentMigration implements DependentMigration
{
/**
* @var MigrationVersionCollection Collection of migration versions that this migration depends on
*/
private MigrationVersionCollection $dependencies;
/**
* Constructor
*/
public function __construct()
{
$this->dependencies = new MigrationVersionCollection([]);
}
/**
* Apply the migration
*/
abstract public function up(ConnectionInterface $connection): void;
/**
* Revert the migration
*/
abstract public function down(ConnectionInterface $connection): void;
/**
* Get the version of this migration
*/
abstract public function getVersion(): MigrationVersion;
/**
* Get the description of this migration
*/
abstract public function getDescription(): string;
/**
* Get the versions of migrations that this migration depends on
*/
public function getDependencies(): MigrationVersionCollection
{
return $this->dependencies;
}
/**
* Check if this migration depends on another migration
*/
public function dependsOn(MigrationVersion $version): bool
{
return $this->dependencies->contains($version);
}
/**
* Add a dependency to this migration
*/
public function addDependency(MigrationVersion $version): self
{
if (! $this->dependsOn($version)) {
$this->dependencies = $this->dependencies->add($version);
}
return $this;
}
/**
* Add multiple dependencies to this migration
*
* @param MigrationVersionCollection|array<MigrationVersion> $versions The versions to depend on
* @return self
*/
public function addDependencies(MigrationVersionCollection|array $versions): self
{
if (is_array($versions)) {
$versions = new MigrationVersionCollection($versions);
}
foreach ($versions as $version) {
$this->addDependency($version);
}
return $this;
}
/**
* Add a dependency by version string
*
* @param string $versionString The version string (e.g., "2023_01_01_120000")
* @return self
*/
public function addDependencyByVersion(string $versionString): self
{
$version = MigrationVersion::fromString($versionString);
return $this->addDependency($version);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ExitCode;
final readonly class ApplyMigrations
@@ -16,15 +17,17 @@ final readonly class ApplyMigrations
}
#[ConsoleCommand('db:migrate', 'Apply all pending migrations')]
public function migrate(): ExitCode
public function migrate(ConsoleInput $input): ExitCode
{
echo "Running migrations...\n";
$skipPreflightChecks = $input->hasOption('skip-preflight') || $input->hasOption('force');
try {
// Use the injected loader that leverages the discovery system
$migrations = $this->loader->loadMigrations();
$executed = $this->runner->migrate($migrations);
$executed = $this->runner->migrate($migrations, $skipPreflightChecks);
if (empty($executed)) {
echo "No migrations to run.\n";
@@ -35,6 +38,9 @@ final readonly class ApplyMigrations
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
echo "❌ Migration failed: " . $e->getMessage() . "\n";
echo "Error details: " . get_class($e) . "\n";
echo "File: " . $e->getFile() . ":" . $e->getLine() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
return ExitCode::SOFTWARE_ERROR;
}

View File

@@ -5,30 +5,45 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Parameter;
use App\Framework\Database\Migration\MigrationGenerator;
final class MakeMigrationCommand
final readonly class MakeMigrationCommand
{
public function __construct(
private readonly MigrationGenerator $generator
private MigrationGenerator $generator
) {
}
/**
* Generate a new migration file
*
* @param string $name Migration name (e.g. CreateUsersTable)
* @param string $domain Domain name for the migration
*/
#[ConsoleCommand('make:migration', 'Generate a new migration file')]
public function __invoke(string $name, string $domain = 'Media'): void
{
if (empty($name)) {
echo "Error: Migration name is required\n";
echo "Usage: make:migration CreateUsersTable [Domain]\n";
public function __invoke(
#[Parameter('Migration name (e.g. CreateUsersTable)', example: 'CreateUsersTable')]
string $name,
#[Parameter('Domain name for organizing migrations')]
string $domain = 'Media'
): ExitCode {
if (empty(trim($name))) {
echo "Error: Migration name cannot be empty\n";
return;
return ExitCode::INVALID_INPUT;
}
try {
$filePath = $this->generator->generate($name, $domain);
echo "Migration created: {$filePath}\n";
echo "Migration created: {$filePath}\n";
return ExitCode::SUCCESS;
} catch (\Exception $e) {
echo "Error creating migration: {$e->getMessage()}\n";
echo "Error creating migration: {$e->getMessage()}\n";
return ExitCode::GENERAL_ERROR;
}
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Table\ConsoleTable;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
use App\Framework\Performance\ValueObjects\Byte;
final readonly class MigrationPerformanceCommand
{
public function __construct(
private ConnectionInterface $connection,
private Clock $clock
) {
}
#[ConsoleCommand('db:migration:performance', 'Display performance statistics for migration executions')]
public function showPerformance(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeln('');
$output->writeln('<info>📊 Migration Performance Report</info>');
$output->writeln(str_repeat('=', 60));
try {
// Check if performance tracking table exists
if (! $this->performanceTableExists()) {
$output->writeln('<warning>⚠️ No performance data available yet.</warning>');
$output->writeln('Performance tracking will be available after running migrations.');
return ExitCode::SUCCESS;
}
// Get performance statistics
$stats = $this->getPerformanceStatistics();
if (empty($stats['total'])) {
$output->writeln('<comment>No migration performance data recorded yet.</comment>');
return ExitCode::SUCCESS;
}
// Display summary
$this->displaySummary($output, $stats);
// Display recent migrations table
$this->displayRecentMigrations($output);
// Display top slowest migrations
$this->displaySlowestMigrations($output);
// Display memory usage statistics
$this->displayMemoryStatistics($output);
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeln('<error>Failed to fetch performance data: ' . $e->getMessage() . '</error>');
return ExitCode::FAILURE;
}
}
private function performanceTableExists(): bool
{
try {
$this->connection->query(SqlQuery::create(
'SELECT 1 FROM migration_performance LIMIT 1'
));
return true;
} catch (\Throwable) {
return false;
}
}
private function getPerformanceStatistics(): array
{
$stats = $this->connection->queryFirst(SqlQuery::create(
'SELECT
COUNT(*) as total,
AVG(duration_ms) as avg_duration,
MIN(duration_ms) as min_duration,
MAX(duration_ms) as max_duration,
AVG(peak_memory) as avg_memory,
MAX(peak_memory) as max_memory,
SUM(CASE WHEN status = "success" THEN 1 ELSE 0 END) as successful,
SUM(CASE WHEN status = "failed" THEN 1 ELSE 0 END) as failed
FROM migration_performance'
));
return $stats ?: [
'total' => 0,
'avg_duration' => 0,
'min_duration' => 0,
'max_duration' => 0,
'avg_memory' => 0,
'max_memory' => 0,
'successful' => 0,
'failed' => 0,
];
}
private function displaySummary(ConsoleOutput $output, array $stats): void
{
$output->writeln('');
$output->writeln('<comment>📈 Overall Statistics</comment>');
$output->writeln(str_repeat('-', 40));
$successRate = $stats['total'] > 0
? round(($stats['successful'] / $stats['total']) * 100, 1)
: 0;
$output->writeln(sprintf('Total Migrations: <info>%d</info>', $stats['total']));
$output->writeln(sprintf(
'Successful: <info>%d</info> (%.1f%%)',
$stats['successful'],
$successRate
));
$output->writeln(sprintf('Failed: <error>%d</error>', $stats['failed']));
$output->writeln('');
$output->writeln(sprintf('Average Duration: <info>%.2f ms</info>', $stats['avg_duration']));
$output->writeln(sprintf('Fastest Migration: <info>%.2f ms</info>', $stats['min_duration']));
$output->writeln(sprintf('Slowest Migration: <warning>%.2f ms</warning>', $stats['max_duration']));
$output->writeln('');
$output->writeln(sprintf(
'Average Memory: <info>%s</info>',
Byte::fromBytes((int)$stats['avg_memory'])->toHumanReadable()
));
$output->writeln(sprintf(
'Peak Memory: <warning>%s</warning>',
Byte::fromBytes((int)$stats['max_memory'])->toHumanReadable()
));
}
private function displayRecentMigrations(ConsoleOutput $output): void
{
$output->writeln('');
$output->writeln('<comment>🕐 Recent Migrations (Last 10)</comment>');
$output->writeln(str_repeat('-', 40));
$recent = $this->connection->query(SqlQuery::create(
'SELECT
migration_version,
status,
duration_ms,
peak_memory,
executed_at
FROM migration_performance
ORDER BY executed_at DESC
LIMIT 10'
));
if ($recent->count() === 0) {
$output->writeln('No recent migrations found.');
return;
}
$table = new ConsoleTable($output);
$table->setHeaders(['Version', 'Status', 'Duration', 'Memory', 'Executed At']);
foreach ($recent as $row) {
$status = $row['status'] === 'success'
? '<info>✅ Success</info>'
: '<error>❌ Failed</error>';
$table->addRow([
$row['migration_version'],
$status,
sprintf('%.2f ms', $row['duration_ms']),
Byte::fromBytes((int)$row['peak_memory'])->toHumanReadable(),
$row['executed_at'],
]);
}
$table->render();
}
private function displaySlowestMigrations(ConsoleOutput $output): void
{
$output->writeln('');
$output->writeln('<comment>🐌 Slowest Migrations (Top 5)</comment>');
$output->writeln(str_repeat('-', 40));
$slowest = $this->connection->query(SqlQuery::create(
'SELECT
migration_version,
duration_ms,
peak_memory,
executed_at
FROM migration_performance
WHERE status = "success"
ORDER BY duration_ms DESC
LIMIT 5'
));
if ($slowest->count() === 0) {
$output->writeln('No migration data available.');
return;
}
$table = new ConsoleTable($output);
$table->setHeaders(['Version', 'Duration', 'Memory', 'Date']);
foreach ($slowest as $row) {
$table->addRow([
$row['migration_version'],
sprintf('<warning>%.2f ms</warning>', $row['duration_ms']),
Byte::fromBytes((int)$row['peak_memory'])->toHumanReadable(),
date('Y-m-d', strtotime($row['executed_at'])),
]);
}
$table->render();
}
private function displayMemoryStatistics(ConsoleOutput $output): void
{
$output->writeln('');
$output->writeln('<comment>💾 Memory Usage Distribution</comment>');
$output->writeln(str_repeat('-', 40));
$memoryRanges = $this->connection->query(SqlQuery::create(
'SELECT
CASE
WHEN peak_memory < 1048576 THEN "< 1 MB"
WHEN peak_memory < 5242880 THEN "1-5 MB"
WHEN peak_memory < 10485760 THEN "5-10 MB"
WHEN peak_memory < 52428800 THEN "10-50 MB"
ELSE "> 50 MB"
END as memory_range,
COUNT(*) as count
FROM migration_performance
GROUP BY memory_range
ORDER BY
CASE memory_range
WHEN "< 1 MB" THEN 1
WHEN "1-5 MB" THEN 2
WHEN "5-10 MB" THEN 3
WHEN "10-50 MB" THEN 4
ELSE 5
END'
));
foreach ($memoryRanges as $range) {
$bar = str_repeat('▓', min(50, (int)($range['count'] * 2)));
$output->writeln(sprintf(
'%10s: %s %d',
$range['memory_range'],
$bar,
$range['count']
));
}
}
#[ConsoleCommand('db:migration:performance:clear', 'Clear migration performance history')]
public function clearPerformance(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if (! $input->hasOption('force')) {
$output->writeln('<warning>⚠️ This will delete all migration performance history.</warning>');
$output->writeln('Use --force to confirm.');
return ExitCode::FAILURE;
}
try {
$this->connection->execute(SqlQuery::create('DELETE FROM migration_performance'));
$output->writeln('<info>✅ Migration performance history cleared.</info>');
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeln('<error>Failed to clear performance data: ' . $e->getMessage() . '</error>');
return ExitCode::FAILURE;
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Exception;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final class MemoryThresholdExceededException extends FrameworkException
{
public static function forMigration(
string $migrationVersion,
Percentage $currentUsage,
Percentage $threshold,
Byte $currentMemory,
Byte $memoryLimit
): self {
return self::create(
ErrorCode::PERF_MEMORY_LIMIT_EXCEEDED,
"Memory threshold exceeded during migration {$migrationVersion}: {$currentUsage->format(1)}% (threshold: {$threshold->format(1)}%)"
)->withData([
'migration_version' => $migrationVersion,
'current_usage_percentage' => $currentUsage->getValue(),
'threshold_percentage' => $threshold->getValue(),
'current_memory' => $currentMemory->toHumanReadable(),
'memory_limit' => $memoryLimit->toHumanReadable(),
'available_memory' => $memoryLimit->subtract($currentMemory)->toHumanReadable(),
])->withMetadata([
'category' => 'memory',
'severity' => 'high',
'action_required' => 'Consider increasing memory limit or running migrations in smaller batches',
]);
}
public static function batchAborted(
Percentage $currentUsage,
Percentage $abortThreshold,
Byte $currentMemory,
Byte $memoryLimit,
int $completedMigrations,
int $totalMigrations
): self {
return self::create(
ErrorCode::PERF_MEMORY_LIMIT_EXCEEDED,
"Migration batch aborted due to critical memory usage: {$currentUsage->format(1)}% (abort threshold: {$abortThreshold->format(1)}%)"
)->withData([
'current_usage_percentage' => $currentUsage->getValue(),
'abort_threshold_percentage' => $abortThreshold->getValue(),
'current_memory' => $currentMemory->toHumanReadable(),
'memory_limit' => $memoryLimit->toHumanReadable(),
'completed_migrations' => $completedMigrations,
'total_migrations' => $totalMigrations,
'remaining_migrations' => $totalMigrations - $completedMigrations,
])->withMetadata([
'category' => 'memory',
'severity' => 'critical',
'action_required' => 'Increase memory limit or run remaining migrations separately',
]);
}
}

View File

@@ -230,6 +230,30 @@ final readonly class MigrationCollection implements Countable, IteratorAggregate
return $this->sortedDescending()->migrations[0];
}
/**
* Filter migrations that haven't been applied yet
*
* @param MigrationVersionCollection $appliedVersions Applied migration versions
* @return self
*/
public function filterByNotApplied(MigrationVersionCollection $appliedVersions): self
{
return $this->filter(
fn (Migration $migration) => !$appliedVersions->contains($migration->getVersion())
);
}
/**
* Find migration by version (alias for getByVersion for compatibility)
*
* @param MigrationVersion $version The version to find
* @return Migration|null The migration or null if not found
*/
public function findByVersion(MigrationVersion $version): ?Migration
{
return $this->getByVersion($version);
}
/**
* Get all migrations as array
*

View File

@@ -5,38 +5,82 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Core\PathProvider;
use App\Framework\DateTime\Clock;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
final readonly class MigrationGenerator
{
public function __construct(
private PathProvider $pathProvider
private readonly PathProvider $pathProvider,
private readonly Clock $clock
) {
}
public function generate(string $name, string $domain = 'Media'): string
{
$timestamp = date('Ymd_His_000');
$version = MigrationVersion::fromTimestamp($timestamp);
$className = $this->toClassName($name);
$fileName = "{$className}.php";
try {
$timestamp = $this->clock->now()->format('Ymd_His_000');
$version = MigrationVersion::fromTimestamp($timestamp);
$className = $this->toClassName($name);
$fileName = "{$className}.php";
$migrationPath = $this->pathProvider->resolvePath("src/Domain/{$domain}/Migrations");
$migrationPath = $this->pathProvider->resolvePath("src/Domain/{$domain}/Migrations");
if (! is_dir($migrationPath)) {
mkdir($migrationPath, 0755, true);
if (! is_dir($migrationPath)) {
if (! mkdir($migrationPath, 0755, true) && ! is_dir($migrationPath)) {
throw FrameworkException::create(
ErrorCode::SYSTEM_RESOURCE_EXHAUSTED,
"Failed to create migration directory"
)->withData(['directory' => $migrationPath]);
}
}
$filePath = $migrationPath . '/' . $fileName;
if (file_exists($filePath)) {
throw FrameworkException::create(
ErrorCode::VAL_DUPLICATE_VALUE,
"Migration file already exists"
)->withContext(
ExceptionContext::forOperation('migration.generate', 'MigrationGenerator')
->withData([
'file_path' => $filePath,
'migration_name' => $name,
'domain' => $domain,
])
);
}
$content = $this->generateMigrationContent($className, $name, $domain, $version->timestamp);
if (file_put_contents($filePath, $content) === false) {
throw FrameworkException::create(
ErrorCode::SYSTEM_RESOURCE_EXHAUSTED,
"Failed to write migration file"
)->withData([
'file_path' => $filePath,
'content_length' => strlen($content),
]);
}
return $filePath;
} catch (FrameworkException $e) {
throw $e;
} catch (\Throwable $e) {
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_FAILED,
"Migration generation failed"
)->withContext(
ExceptionContext::forOperation('migration.generate', 'MigrationGenerator')
->withData([
'migration_name' => $name,
'domain' => $domain,
'error_message' => $e->getMessage(),
])
);
}
$filePath = $migrationPath . '/' . $fileName;
if (file_exists($filePath)) {
throw new \RuntimeException("Migration file already exists: {$filePath}");
}
$content = $this->generateMigrationContent($className, $name, $domain, $version->timestamp);
file_put_contents($filePath, $content);
return $filePath;
}
private function toClassName(string $name): string

View File

@@ -23,9 +23,28 @@ final readonly class MigrationLoader
$interfaceMappings = $this->discoveryRegistry->interfaces->findMappingsForInterface(Migration::class);
foreach ($interfaceMappings as $mapping) {
/** @var Migration $migrationInstance */
$migrationInstance = $this->container->get($mapping->implementation->getFullyQualified());
$migrations[] = $migrationInstance;
$className = $mapping->implementation->getFullyQualified();
// Skip interfaces and abstract classes - only load concrete classes
if (! class_exists($className)) {
continue;
}
$reflection = new \ReflectionClass($className);
if ($reflection->isInterface() || $reflection->isAbstract()) {
continue;
}
try {
/** @var Migration $migrationInstance */
$migrationInstance = $this->container->get($className);
$migrations[] = $migrationInstance;
} catch (\Throwable $e) {
// Skip classes that can't be instantiated
error_log("Warning: Could not instantiate migration class {$className}: " . $e->getMessage());
continue;
}
}
return MigrationCollection::fromArray($migrations)->sorted();

View File

@@ -5,252 +5,415 @@ declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\Transaction;
use App\Framework\Database\Migration\Services\MigrationDatabaseManager;
use App\Framework\Database\Migration\Services\MigrationErrorAnalyzer;
use App\Framework\Database\Migration\Services\MigrationLogger;
use App\Framework\Database\Migration\Services\MigrationPerformanceTracker;
use App\Framework\Database\Migration\Services\MigrationValidator;
use App\Framework\Database\Migration\ValueObjects\MemoryThresholds;
use App\Framework\Database\Migration\ValueObjects\MigrationTableConfig;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\DateTime\Clock;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
use App\Framework\Logging\Logger;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\OperationTracker;
use App\Framework\Performance\PerformanceReporter;
use App\Framework\Performance\Repository\PerformanceMetricsRepository;
final readonly class MigrationRunner
{
/**
* @var MigrationDependencyGraph Dependency graph for migrations
*/
private MigrationDependencyGraph $dependencyGraph;
private MigrationDatabaseManager $databaseManager;
private MigrationPerformanceTracker $performanceTracker;
private MigrationLogger $migrationLogger;
private MigrationValidator $validator;
private MigrationErrorAnalyzer $errorAnalyzer;
public function __construct(
private ConnectionInterface $connection,
private string $migrationsTable = 'migrations'
private DatabasePlatform $platform,
private Clock $clock,
?MigrationTableConfig $tableConfig = null,
?Logger $logger = null,
?OperationTracker $operationTracker = null,
?MemoryMonitor $memoryMonitor = null,
?PerformanceReporter $performanceReporter = null,
?MemoryThresholds $memoryThresholds = null,
?PerformanceMetricsRepository $performanceMetricsRepository = null
) {
$this->ensureMigrationsTable();
$this->dependencyGraph = new MigrationDependencyGraph();
$effectiveTableConfig = $tableConfig ?? MigrationTableConfig::default();
// Initialize service classes
$this->databaseManager = new MigrationDatabaseManager(
$this->connection,
$this->platform,
$this->clock,
$effectiveTableConfig
);
$this->performanceTracker = new MigrationPerformanceTracker(
$operationTracker,
$memoryMonitor,
$performanceMetricsRepository,
$logger,
$memoryThresholds
);
$this->migrationLogger = new MigrationLogger($logger);
$this->validator = new MigrationValidator(
$this->connection,
$this->platform
);
$this->errorAnalyzer = new MigrationErrorAnalyzer();
}
/**
* Run migrations
*
* @param MigrationCollection $migrations The migrations to run
* @return array<string> List of executed migration versions
*/
public function migrate(MigrationCollection $migrations): array
public function migrate(MigrationCollection $migrations, bool $skipPreflightChecks = false): array
{
$this->databaseManager->ensureMigrationsTable();
$executedMigrations = [];
$appliedVersions = $this->getAppliedVersions();
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
// Build the dependency graph
$this->dependencyGraph->buildGraph($migrations);
// Pre-flight checks
if (! $skipPreflightChecks) {
$this->runPreFlightChecks($migrations, $appliedVersions);
}
// Get migrations in the correct execution order based on dependencies
// Filter and order migrations
$pendingMigrations = $migrations->filterByNotApplied($appliedVersions);
if ($pendingMigrations->isEmpty()) {
$this->migrationLogger->logMigration('none', 'No pending migrations', 'Skipped');
return [];
}
$this->dependencyGraph->buildGraph($pendingMigrations);
$orderedMigrations = $this->dependencyGraph->getExecutionOrder();
$totalMigrations = $orderedMigrations->count();
// Start batch tracking
$batchOperationId = 'migration_batch_' . uniqid();
$this->performanceTracker->startBatchOperation($batchOperationId, $totalMigrations);
$currentPosition = 0;
foreach ($orderedMigrations as $migration) {
$version = $migration->getVersion()->toString();
if ($appliedVersions->containsString($version)) {
$currentPosition++;
if ($appliedVersions->contains($migration->getVersion())) {
continue;
}
// Check if all dependencies are applied
if ($migration instanceof DependentMigration) {
$missingDependencies = [];
foreach ($migration->getDependencies() as $dependencyVersion) {
$dependencyVersionString = $dependencyVersion->toString();
if (! $appliedVersions->containsString($dependencyVersionString) &&
! in_array($dependencyVersionString, $executedMigrations)) {
$missingDependencies[] = $dependencyVersionString;
}
}
if (! empty($missingDependencies)) {
throw new DatabaseException(
"Cannot apply migration {$version} because it depends on migrations that have not been applied: " .
implode(', ', $missingDependencies)
);
}
}
try {
Transaction::run($this->connection, function () use ($migration, $version) {
echo "Migrating: {$version} - {$migration->getDescription()}\n";
// Start individual migration tracking
$migrationOperationId = "migration_{$version}";
$this->performanceTracker->startMigrationOperation(
$migrationOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalMigrations,
$batchOperationId
);
// Check memory thresholds before migration
$this->performanceTracker->checkMemoryThresholds($version, $currentPosition - 1, $totalMigrations);
// Execute migration in transaction
$this->connection->beginTransaction();
try {
$migration->up($this->connection);
$this->databaseManager->recordMigrationExecution($migration, $version);
$this->connection->execute(
"INSERT INTO {$this->migrationsTable} (version, description, executed_at) VALUES (?, ?, ?)",
[$version, $migration->getDescription(), date('Y-m-d H:i:s')]
);
});
if ($this->connection->inTransaction()) {
$this->connection->commit();
}
// Update operation with successful execution
$this->performanceTracker->updateBatchOperation($batchOperationId, [
'items_processed' => $currentPosition,
]);
} catch (\Throwable $e) {
if ($this->connection->inTransaction()) {
$this->connection->rollback();
}
throw $e;
}
$executedMigrations[] = $version;
echo "Migrated: {$version}\n";
// Complete individual migration tracking
$migrationSnapshot = $this->performanceTracker->completeMigrationOperation(
$migrationOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalMigrations,
$batchOperationId
);
$this->migrationLogger->logMigration($version, $migration->getDescription(), 'Migrated');
$this->migrationLogger->logMigrationProgress($version, $currentPosition, $totalMigrations, 'completed', [
'execution_time' => $migrationSnapshot?->duration?->toSeconds(),
'memory_used' => $migrationSnapshot?->memoryDelta?->toHumanReadable(),
]);
// Post-migration memory check
$this->performanceTracker->logPostMigrationMemory($version, $migrationSnapshot);
} catch (\Throwable $e) {
throw new DatabaseException(
"Migration {$version} failed: {$e->getMessage()}",
0,
// Track failed individual migration
$failedSnapshot = $this->performanceTracker->failMigrationOperation(
$migrationOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalMigrations,
$batchOperationId,
$e
);
// Track failed batch
$this->performanceTracker->failBatchOperation($batchOperationId, $e);
// Enhanced error reporting with recovery hints
$recoveryHints = $this->errorAnalyzer->generateMigrationRecoveryHints($migration, $e, $executedMigrations);
$migrationContext = $this->errorAnalyzer->analyzeMigrationContext($migration, $version, $e);
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_FAILED,
"Migration {$version} failed: {$e->getMessage()}"
)->withContext(
ExceptionContext::forOperation('migration.execute', 'MigrationRunner')
->withData([
'migration_version' => $version,
'migration_description' => $migration->getDescription(),
'executed_migrations' => $executedMigrations,
'total_migrations' => $totalMigrations,
'current_position' => $currentPosition,
])
->withDebug($migrationContext)
->withMetadata([
'recovery_hints' => $recoveryHints,
'can_retry' => $this->errorAnalyzer->canRetryMigration($e),
'rollback_recommended' => $this->errorAnalyzer->isRollbackRecommended($e, $executedMigrations),
])
);
}
}
// Complete batch tracking
$batchSnapshot = $this->performanceTracker->completeBatchOperation($batchOperationId);
$this->migrationLogger->logMigrationBatchSummary($executedMigrations, $totalMigrations, $batchSnapshot);
return $executedMigrations;
}
/**
* Rollback migrations
*
* @param MigrationCollection $migrations The migrations to rollback from
* @param int $steps Number of migrations to rollback
* @return MigrationCollection List of rolled back migration versions
*/
public function rollback(MigrationCollection $migrations, int $steps = 1): MigrationCollection
public function rollback(MigrationCollection $migrations, int $steps = 1): array
{
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
if ($appliedVersions->isEmpty()) {
$this->migrationLogger->logMigration('none', 'No migrations to rollback', 'Skipped');
return [];
}
$versionsToRollback = array_slice(array_reverse($appliedVersions->toArray()), 0, $steps);
$rolledBackMigrations = [];
$appliedVersions = $this->getAppliedVersions();
$totalRollbacks = count($versionsToRollback);
// Build the dependency graph
$this->dependencyGraph->buildGraph($migrations);
// Start rollback batch tracking
$rollbackBatchId = 'rollback_batch_' . uniqid();
$this->performanceTracker->startBatchOperation($rollbackBatchId, $totalRollbacks);
// Use the collection's sortedDescending method to get migrations in reverse order
$sortedMigrations = $migrations->sortedDescending();
// Create a map of version strings to migrations for easy lookup
$migrationMap = [];
foreach ($migrations as $migration) {
$migrationMap[$migration->getVersion()->toString()] = $migration;
}
// Create a list of versions that can be rolled back
$versionsToRollback = [];
$count = 0;
foreach ($sortedMigrations as $migration) {
if ($count >= $steps) {
break;
}
$version = $migration->getVersion()->toString();
if (! $appliedVersions->containsString($version)) {
continue;
}
// Check if any applied migrations depend on this one
$dependants = $this->dependencyGraph->getDependants($version);
$appliedDependants = [];
foreach ($dependants as $dependant) {
if ($appliedVersions->containsString($dependant) && ! in_array($dependant, $versionsToRollback)) {
$appliedDependants[] = $dependant;
}
}
// If there are applied dependants, we can't roll back this migration
if (! empty($appliedDependants)) {
echo "Cannot roll back {$version} because the following migrations depend on it: " . implode(', ', $appliedDependants) . "\n";
continue;
}
$versionsToRollback[] = $version;
$count++;
}
// Roll back the migrations in the correct order
$currentPosition = 0;
foreach ($versionsToRollback as $version) {
$migration = $migrationMap[$version];
$currentPosition++;
$migration = $migrations->findByVersion(MigrationVersion::fromTimestamp($version));
if (! $migration) {
$this->migrationLogger->logRollbackSkipped($version, ['reason' => 'Migration class not found']);
continue;
}
try {
Transaction::run($this->connection, function () use ($migration, $version) {
echo "Rolling back: {$version} - {$migration->getDescription()}\n";
// Validate rollback safety
$this->validator->validateRollbackSafety($migration, $version);
// Real-time rollback progress tracking
$this->migrationLogger->logRollbackProgress($version, $currentPosition, $totalRollbacks, 'starting');
// Start individual rollback tracking
$rollbackOperationId = "rollback_{$version}";
$this->performanceTracker->startRollbackOperation(
$rollbackOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalRollbacks,
$rollbackBatchId,
$steps
);
// Execute rollback in transaction
$this->connection->beginTransaction();
try {
$migration->down($this->connection);
$this->databaseManager->recordMigrationRollback($version);
$this->connection->execute(
"DELETE FROM {$this->migrationsTable} WHERE version = ?",
[$version]
);
});
if ($this->connection->inTransaction()) {
$this->connection->commit();
}
} catch (\Throwable $e) {
if ($this->connection->inTransaction()) {
$this->connection->rollback();
}
throw $e;
}
$rolledBackMigrations[] = $migration;
echo "Rolled back: {$version}\n";
// Complete individual rollback tracking
$rollbackSnapshot = $this->performanceTracker->completeRollbackOperation(
$rollbackOperationId,
$version,
$migration->getDescription(),
$currentPosition,
$totalRollbacks,
$rollbackBatchId,
$steps
);
$this->migrationLogger->logMigration($version, $migration->getDescription(), 'Rolled back');
$this->migrationLogger->logRollbackProgress($version, $currentPosition, $totalRollbacks, 'completed', [
'execution_time' => $rollbackSnapshot?->duration?->toSeconds(),
'memory_used' => $rollbackSnapshot?->memoryDelta?->toHumanReadable(),
]);
// Post-rollback memory check
$this->performanceTracker->logPostRollbackMemory($version, $rollbackSnapshot);
} catch (\Throwable $e) {
throw new DatabaseException(
"Rollback {$version} failed: {$e->getMessage()}",
0,
$e
// Generate rollback recovery hints
$remainingRollbacks = array_slice($versionsToRollback, $currentPosition);
$recoveryHints = $this->errorAnalyzer->generateRollbackRecoveryHints($migration, $e, $remainingRollbacks);
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_ROLLBACK_FAILED,
"Rollback failed for migration {$version}: {$e->getMessage()}"
)->withContext(
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
->withData([
'migration_version' => $version,
'rollback_steps' => $steps,
'completed_rollbacks' => count($rolledBackMigrations),
'failed_migration' => $version,
])
->withMetadata([
'recovery_hints' => $recoveryHints,
'can_continue' => $this->errorAnalyzer->canContinueRollback($remainingRollbacks),
])
);
}
}
return new MigrationCollection(...$rolledBackMigrations);
// Complete rollback batch tracking
$rollbackSnapshot = $this->performanceTracker->completeBatchOperation($rollbackBatchId);
$this->migrationLogger->logRollbackBatchSummary($rolledBackMigrations, $totalRollbacks, $rollbackSnapshot);
return $rolledBackMigrations;
}
/**
* Get status of all migrations
*
* @param MigrationCollection $migrations The migrations to check status for
* @return MigrationStatusCollection Collection of migration statuses
* Get applied migration versions
*/
public function getStatus(MigrationCollection $migrations): MigrationStatusCollection
public function getAppliedVersions(): array
{
$appliedVersions = $this->getAppliedVersions();
return $this->databaseManager->getAppliedVersions();
}
/**
* Get status information for all migrations
*
* @return array<MigrationStatus>
*/
public function getStatus(MigrationCollection $migrations): array
{
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
$statuses = [];
foreach ($migrations as $migration) {
$version = $migration->getVersion();
$applied = $appliedVersions->contains($version);
$statuses[] = $applied
? MigrationStatus::applied($version, $migration->getDescription())
: MigrationStatus::pending($version, $migration->getDescription());
$statuses[] = new MigrationStatus(
version: $version,
description: $migration->getDescription(),
applied: $applied
);
}
return new MigrationStatusCollection($statuses);
return $statuses;
}
public function getAppliedVersions(): MigrationVersionCollection
private function runPreFlightChecks(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): void
{
$versionStrings = $this->connection->queryColumn(
"SELECT version FROM {$this->migrationsTable} ORDER BY executed_at"
$results = $this->validator->runPreFlightChecks($migrations, $appliedVersions);
$dependencyResults = $this->validator->validateMigrationDependencies($migrations, $appliedVersions);
// Extract critical issues and warnings
$criticalIssues = [];
$warnings = [];
foreach ($results as $check => $result) {
if ($result['status'] === 'fail' && ($result['severity'] ?? 'info') === 'critical') {
$criticalIssues[] = $check . ': ' . $result['message'];
} elseif ($result['status'] === 'warning' || ($result['severity'] ?? 'info') === 'warning') {
$warnings[] = $check . ': ' . $result['message'];
}
}
$this->migrationLogger->logPreFlightResults($results, $criticalIssues, $warnings);
$this->migrationLogger->logDependencyValidationIssues(
$dependencyResults['orphaned_dependencies'],
$dependencyResults['partial_chains'],
$dependencyResults['ordering_issues']
);
return MigrationVersionCollection::fromStrings($versionStrings);
}
private function ensureMigrationsTable(): void
{
// Use database-agnostic approach
$driver = $this->connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
$sql = match($driver) {
'mysql' => "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
id INT PRIMARY KEY AUTO_INCREMENT,
version VARCHAR(20) NOT NULL UNIQUE COMMENT 'Format: YYYY_MM_DD_HHMMSS',
description TEXT,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_version (version),
INDEX idx_executed_at (executed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
'pgsql' => "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
id SERIAL PRIMARY KEY,
version VARCHAR(20) NOT NULL UNIQUE,
description TEXT,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
'sqlite' => "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
description TEXT,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
default => throw new \RuntimeException("Unsupported database driver: {$driver}")
};
$this->connection->execute($sql);
// Create indexes for PostgreSQL separately
if ($driver === 'pgsql') {
$this->connection->execute("CREATE INDEX IF NOT EXISTS idx_{$this->migrationsTable}_version ON {$this->migrationsTable} (version)");
$this->connection->execute("CREATE INDEX IF NOT EXISTS idx_{$this->migrationsTable}_executed_at ON {$this->migrationsTable} (executed_at)");
// Throw exception if critical issues found
if (! empty($criticalIssues)) {
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_PREFLIGHT_FAILED,
'Pre-flight checks failed with critical issues'
)->withData([
'critical_issues' => $criticalIssues,
'warnings' => $warnings,
'full_results' => $results,
]);
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Migrations;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Schema\Contracts\SchemaBuilderFactoryInterface;
use App\Framework\Database\Schema\Contracts\SchemaBuilderInterface;
final readonly class CreatePerformanceMetricsTable implements Migration
{
public function up(ConnectionInterface $connection): void
{
$schemaBuilder = SchemaBuilderFactoryInterface::create($connection);
$schemaBuilder->createTable('performance_metrics', function (SchemaBuilderInterface $table) {
$table->id();
$table->string('operation_id', 255)->notNull();
$table->string('operation_type', 100)->notNull();
$table->string('category', 50)->notNull();
$table->string('migration_version', 100)->nullable();
$table->integer('execution_time_ms')->notNull();
$table->bigInteger('memory_start_bytes')->notNull();
$table->bigInteger('memory_end_bytes')->notNull();
$table->bigInteger('memory_peak_bytes')->notNull();
$table->bigInteger('memory_delta_bytes')->notNull();
$table->boolean('success')->notNull()->default(true);
$table->text('error_message')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
// Indexes
$table->index('operation_type');
$table->index('category');
$table->index('migration_version');
$table->index('created_at');
$table->index('success');
});
}
public function down(ConnectionInterface $connection): void
{
$schemaBuilder = SchemaBuilderFactoryInterface::create($connection);
$schemaBuilder->dropTable('performance_metrics');
}
public function getDescription(): string
{
return 'Create performance metrics table for migration and system performance tracking';
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2024_09_28_233500');
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\ValueObjects\MigrationTableConfig;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
final readonly class MigrationDatabaseManager
{
public function __construct(
private ConnectionInterface $connection,
private DatabasePlatform $platform,
private Clock $clock,
private MigrationTableConfig $tableConfig
) {
}
public function ensureMigrationsTable(): void
{
if ($this->tableExists($this->tableConfig->tableName)) {
return;
}
$sql = $this->createMigrationsTableSQL($this->tableConfig->tableName);
$this->connection->execute(SqlQuery::create($sql));
}
public function recordMigrationExecution(Migration $migration, string $version): void
{
$now = $this->clock->now()->format('Y-m-d H:i:s');
$sql = "INSERT INTO {$this->tableConfig->tableName} (version, description, executed_at) VALUES (?, ?, ?)";
$this->connection->execute(SqlQuery::create($sql, [$version, $migration->getDescription(), $now]));
}
public function recordMigrationRollback(string $version): void
{
$sql = "DELETE FROM {$this->tableConfig->tableName} WHERE version = ?";
$this->connection->execute(SqlQuery::create($sql, [$version]));
}
public function getAppliedVersions(): array
{
$this->ensureMigrationsTable();
$sql = "SELECT version FROM {$this->tableConfig->tableName} ORDER BY executed_at ASC";
return $this->connection->queryColumn(SqlQuery::create($sql));
}
public function tableExists(string $tableName): bool
{
try {
$sql = $this->platform->getTableExistsSQL($tableName);
$result = $this->connection->queryScalar(SqlQuery::create($sql));
return (bool) $result;
} catch (\Throwable $e) {
return false;
}
}
private function createMigrationsTableSQL(string $tableName): string
{
return match ($this->platform->getName()) {
'mysql' => "CREATE TABLE {$tableName} (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_version (version),
INDEX idx_executed_at (executed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'postgresql' => "CREATE TABLE {$tableName} (
id SERIAL PRIMARY KEY,
version VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
executed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_{$tableName}_version ON {$tableName} (version);
CREATE INDEX idx_{$tableName}_executed_at ON {$tableName} (executed_at);",
'sqlite' => "CREATE TABLE {$tableName} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_{$tableName}_version ON {$tableName} (version);
CREATE INDEX idx_{$tableName}_executed_at ON {$tableName} (executed_at);",
default => throw new \RuntimeException("Unsupported database platform: {$this->platform->getName()}")
};
}
}

View File

@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Database\Migration\Migration;
final readonly class MigrationErrorAnalyzer
{
public function generateMigrationRecoveryHints(Migration $migration, \Throwable $error, array $executedMigrations): array
{
$hints = [];
// Analyze error type and provide specific guidance
if ($this->isDatabaseConnectionError($error)) {
$hints[] = 'Database connection lost - check database server status';
$hints[] = 'Verify database credentials and network connectivity';
$hints[] = 'Consider retrying after connection is restored';
}
if ($this->isPermissionError($error)) {
$hints[] = 'Database permission denied - verify user privileges';
$hints[] = 'Ensure migration user has CREATE, ALTER, DROP permissions';
$hints[] = 'Check if migration user can access the target database';
}
if ($this->isSyntaxError($error)) {
$hints[] = 'SQL syntax error detected - review migration SQL';
$hints[] = 'Check for database-specific syntax requirements';
$hints[] = 'Validate migration against target database version';
}
if ($this->isDiskSpaceError($error)) {
$hints[] = 'Insufficient disk space - free up storage';
$hints[] = 'Consider running migrations in smaller batches';
$hints[] = 'Check database temp directory space';
}
if ($this->isConstraintViolation($error)) {
$hints[] = 'Database constraint violation - check data integrity';
$hints[] = 'Review foreign key relationships and unique constraints';
$hints[] = 'Consider data cleanup before retry';
}
// General recovery recommendations
if ($this->canRetryMigration($error)) {
$hints[] = 'Migration can be safely retried after resolving the issue';
}
if ($this->isRollbackRecommended($error, $executedMigrations)) {
$hints[] = 'Consider rolling back completed migrations before retry';
$hints[] = sprintf('Rollback %d successfully completed migrations', count($executedMigrations));
}
if (empty($hints)) {
$hints[] = 'Review migration code and database state manually';
$hints[] = 'Check application logs for additional context';
}
return $hints;
}
public function generateRollbackRecoveryHints(Migration $migration, \Throwable $error, array $remainingRollbacks): array
{
$hints = [];
if ($this->isDatabaseConnectionError($error)) {
$hints[] = 'Database connection lost during rollback - check server status';
$hints[] = 'Rollback operation can be continued after connection restore';
}
if ($this->isDataLossRisk($error)) {
$hints[] = 'Rollback may cause data loss - backup before continuing';
$hints[] = 'Review rollback SQL for irreversible operations';
}
if ($this->canContinueRollback($remainingRollbacks)) {
$hints[] = sprintf('Can continue rollback for %d remaining migrations', count($remainingRollbacks));
} else {
$hints[] = 'Manual intervention required - some rollbacks may be unsafe';
$hints[] = 'Review each remaining migration rollback individually';
}
if ($this->requiresManualIntervention($error)) {
$hints[] = 'Automatic rollback failed - manual database intervention required';
$hints[] = 'Check database state and manually reverse changes if needed';
}
return $hints;
}
public function analyzeMigrationContext(Migration $migration, string $version, \Throwable $error): array
{
return [
'migration_info' => [
'version' => $version,
'description' => $migration->getDescription(),
'class' => get_class($migration),
],
'error_analysis' => [
'type' => get_class($error),
'message' => $error->getMessage(),
'code' => $error->getCode(),
'file' => $error->getFile(),
'line' => $error->getLine(),
'is_retryable' => $this->canRetryMigration($error),
'is_connection_error' => $this->isDatabaseConnectionError($error),
'is_permission_error' => $this->isPermissionError($error),
'is_syntax_error' => $this->isSyntaxError($error),
'requires_manual_intervention' => $this->requiresManualIntervention($error),
],
'recovery_recommendations' => $this->generateMigrationRecoveryHints($migration, $error, []),
'database_state' => $this->getDatabaseStateDebugInfo(),
'system_info' => [
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
'time_limit' => ini_get('max_execution_time'),
'php_version' => PHP_VERSION,
],
];
}
public function canRetryMigration(\Throwable $error): bool
{
// These errors are typically retryable
return $this->isDatabaseConnectionError($error) ||
$this->isTimeoutError($error) ||
$this->isDiskSpaceError($error) ||
$this->isTemporaryLockError($error);
}
public function isRollbackRecommended(\Throwable $error, array $executedMigrations): bool
{
// Recommend rollback for serious structural errors
return ! empty($executedMigrations) && (
$this->isConstraintViolation($error) ||
$this->isSyntaxError($error) ||
$this->isPermissionError($error)
);
}
public function canContinueRollback(array $remainingRollbacks): bool
{
// Can continue rollback if there are migrations to roll back
return ! empty($remainingRollbacks);
}
public function requiresManualIntervention(\Throwable $error): bool
{
// These errors typically require manual database intervention
return $this->isCorruptionError($error) ||
$this->isDataIntegrityError($error) ||
$this->isStructuralError($error);
}
private function isDatabaseConnectionError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'connection',
'server has gone away',
'lost connection',
'timeout'
);
}
private function isPermissionError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'access denied',
'permission',
'privilege',
'unauthorized'
);
}
private function isSyntaxError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'syntax error',
'sql syntax',
'invalid sql',
'parse error'
);
}
private function isDiskSpaceError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'disk full',
'no space',
'disk space',
'storage full'
);
}
private function isConstraintViolation(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'constraint',
'foreign key',
'unique',
'duplicate'
);
}
private function isTimeoutError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'timeout',
'time limit',
'execution time'
);
}
private function isTemporaryLockError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'lock',
'deadlock',
'table is locked'
);
}
private function isDataLossRisk(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'drop',
'delete',
'truncate'
);
}
private function isCorruptionError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'corrupt',
'damaged',
'invalid header'
);
}
private function isDataIntegrityError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'integrity',
'check constraint',
'data validation'
);
}
private function isStructuralError(\Throwable $error): bool
{
return $this->containsAny(
$error->getMessage(),
'table structure',
'schema',
'column does not exist'
);
}
/**
* Check if a string contains any of the given matches (case-insensitive)
*/
private function containsAny(string $haystack, string ...$needles): bool
{
$lowerHaystack = strtolower($haystack);
foreach ($needles as $needle) {
if (str_contains($lowerHaystack, strtolower($needle))) {
return true;
}
}
return false;
}
private function getDatabaseStateDebugInfo(): array
{
return [
'active_connections' => 'unknown', // Would need platform-specific queries
'transaction_state' => 'unknown',
'lock_status' => 'unknown',
'note' => 'Database state debug info requires platform-specific implementation',
];
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Logging\Logger;
final readonly class MigrationLogger
{
public function __construct(
private ?Logger $logger = null
) {
}
public function logMigration(string $version, string $description, string $action): void
{
$this->logger?->info("Migration {$action}", [
'version' => $version,
'description' => $description,
'action' => $action,
]);
}
public function logRollbackSkipped(string $version, array $dependants): void
{
$this->logger?->warning('Rollback skipped due to dependant migrations', [
'version' => $version,
'dependants' => $dependants,
'reason' => 'Migration has dependencies that would be broken',
]);
}
public function logMigrationProgress(string $version, int $current, int $total, string $status, array $details = []): void
{
$percentage = $total > 0 ? round(($current / $total) * 100, 1) : 0;
$this->logger?->info("Migration progress: {$status}", array_merge([
'migration_version' => $version,
'current' => $current,
'total' => $total,
'percentage' => $percentage,
'status' => $status,
], $details));
}
public function logRollbackProgress(string $version, int $current, int $total, string $status, array $details = []): void
{
$percentage = $total > 0 ? round(($current / $total) * 100, 1) : 0;
$this->logger?->info("Rollback progress: {$status}", array_merge([
'migration_version' => $version,
'current' => $current,
'total' => $total,
'percentage' => $percentage,
'status' => $status,
], $details));
}
public function logMigrationBatchSummary(array $executedMigrations, int $totalMigrations, mixed $batchSnapshot = null): void
{
$this->logger?->info('Migration batch completed', [
'executed_migrations' => count($executedMigrations),
'total_migrations' => $totalMigrations,
'success_rate' => $totalMigrations > 0 ? round((count($executedMigrations) / $totalMigrations) * 100, 1) : 0,
'batch_execution_time' => $batchSnapshot?->duration?->toSeconds(),
'batch_memory_delta' => $batchSnapshot?->memoryDelta?->toHumanReadable(),
'executed_versions' => $executedMigrations,
]);
}
public function logRollbackBatchSummary(array $rolledBackMigrations, int $totalRollbacks, mixed $rollbackSnapshot = null): void
{
$this->logger?->info('Rollback batch completed', [
'rolled_back_migrations' => count($rolledBackMigrations),
'total_rollbacks' => $totalRollbacks,
'success_rate' => $totalRollbacks > 0 ? round((count($rolledBackMigrations) / $totalRollbacks) * 100, 1) : 0,
'batch_execution_time' => $rollbackSnapshot?->duration?->toSeconds(),
'batch_memory_delta' => $rollbackSnapshot?->memoryDelta?->toHumanReadable(),
'rolled_back_versions' => array_map(fn ($migration) => $migration->getVersion()->toString(), $rolledBackMigrations),
]);
}
public function logPreFlightResults(array $results, array $criticalIssues, array $warnings): void
{
if (! empty($criticalIssues)) {
$this->logger?->error('Pre-flight checks found critical issues', [
'critical_issues' => $criticalIssues,
'warnings' => $warnings,
'all_results' => $results,
]);
} elseif (! empty($warnings)) {
$this->logger?->warning('Pre-flight checks found warnings', [
'warnings' => $warnings,
'all_results' => $results,
]);
} else {
$this->logger?->info('Pre-flight checks passed', [
'results' => $results,
]);
}
}
public function logDependencyValidationIssues(array $orphanedDependencies, array $partialChains, array $orderingIssues): void
{
if (! empty($orphanedDependencies) || ! empty($partialChains) || ! empty($orderingIssues)) {
$this->logger?->warning('Migration dependency validation issues found', [
'orphaned_dependencies' => $orphanedDependencies,
'partial_chains' => $partialChains,
'ordering_issues' => $orderingIssues,
]);
}
}
}

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Database\Migration\Exception\MemoryThresholdExceededException;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Migration\ValueObjects\MemoryThresholds;
use App\Framework\Logging\Logger;
use App\Framework\Performance\Entity\PerformanceMetric;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\OperationTracker;
use App\Framework\Performance\PerformanceCategory;
use App\Framework\Performance\Repository\PerformanceMetricsRepository;
final readonly class MigrationPerformanceTracker
{
public function __construct(
private ?OperationTracker $operationTracker = null,
private ?MemoryMonitor $memoryMonitor = null,
private ?PerformanceMetricsRepository $performanceMetricsRepository = null,
private ?Logger $logger = null,
private ?MemoryThresholds $memoryThresholds = null
) {
}
public function startBatchOperation(string $batchId, int $totalMigrations): void
{
$this->operationTracker?->startOperation(
$batchId,
PerformanceCategory::DATABASE,
[
'operation_type' => 'migration_batch',
'total_migrations' => $totalMigrations,
'batch_id' => $batchId,
]
);
}
public function startMigrationOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId
): void {
$this->operationTracker?->startOperation(
$operationId,
PerformanceCategory::DATABASE,
[
'operation_type' => 'migration_execution',
'migration_version' => $version,
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
]
);
}
public function startRollbackOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId,
int $steps
): void {
$this->operationTracker?->startOperation(
$operationId,
PerformanceCategory::DATABASE,
[
'operation_type' => 'rollback_execution',
'migration_version' => $version,
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
'rollback_steps' => $steps,
]
);
}
public function completeMigrationOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId
): mixed {
$snapshot = $this->operationTracker?->completeOperation($operationId);
// Persist performance metrics to database
if ($snapshot && $this->performanceMetricsRepository) {
$performanceMetric = PerformanceMetric::fromPerformanceSnapshot(
operationId: $operationId,
operationType: 'migration_execution',
category: PerformanceCategory::DATABASE,
executionTime: $snapshot->duration,
memoryStart: $snapshot->memoryStart,
memoryEnd: $snapshot->memoryEnd,
memoryPeak: $snapshot->memoryPeak,
memoryDelta: $snapshot->memoryDelta,
success: true,
migrationVersion: MigrationVersion::fromTimestamp($version),
metadata: [
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
]
);
try {
$this->performanceMetricsRepository->save($performanceMetric);
} catch (\Throwable $e) {
$this->logger?->warning('Failed to persist migration performance metrics', [
'migration_version' => $version,
'error' => $e->getMessage(),
]);
}
}
return $snapshot;
}
public function completeRollbackOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId,
int $steps
): mixed {
$snapshot = $this->operationTracker?->completeOperation($operationId);
// Persist rollback performance metrics to database
if ($snapshot && $this->performanceMetricsRepository) {
$performanceMetric = PerformanceMetric::fromPerformanceSnapshot(
operationId: $operationId,
operationType: 'rollback_execution',
category: PerformanceCategory::DATABASE,
executionTime: $snapshot->duration,
memoryStart: $snapshot->memoryStart,
memoryEnd: $snapshot->memoryEnd,
memoryPeak: $snapshot->memoryPeak,
memoryDelta: $snapshot->memoryDelta,
success: true,
migrationVersion: MigrationVersion::fromTimestamp($version),
metadata: [
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
'rollback_steps' => $steps,
]
);
try {
$this->performanceMetricsRepository->save($performanceMetric);
} catch (\Throwable $e) {
$this->logger?->warning('Failed to persist rollback performance metrics', [
'migration_version' => $version,
'error' => $e->getMessage(),
]);
}
}
return $snapshot;
}
public function failMigrationOperation(
string $operationId,
string $version,
string $description,
int $position,
int $total,
string $batchId,
\Throwable $error
): mixed {
$snapshot = $this->operationTracker?->failOperation($operationId, $error);
// Persist failed migration performance metrics
if ($snapshot && $this->performanceMetricsRepository) {
$performanceMetric = PerformanceMetric::fromPerformanceSnapshot(
operationId: $operationId,
operationType: 'migration_execution',
category: PerformanceCategory::DATABASE,
executionTime: $snapshot->duration,
memoryStart: $snapshot->memoryStart,
memoryEnd: $snapshot->memoryEnd,
memoryPeak: $snapshot->memoryPeak,
memoryDelta: $snapshot->memoryDelta,
success: false,
errorMessage: $error->getMessage(),
migrationVersion: MigrationVersion::fromTimestamp($version),
metadata: [
'migration_description' => $description,
'position' => $position,
'total' => $total,
'batch_id' => $batchId,
'error_type' => get_class($error),
]
);
try {
$this->performanceMetricsRepository->save($performanceMetric);
} catch (\Throwable $saveException) {
$this->logger?->warning('Failed to persist failed migration performance metrics', [
'migration_version' => $version,
'error' => $saveException->getMessage(),
'original_error' => $error->getMessage(),
]);
}
}
return $snapshot;
}
public function completeBatchOperation(string $batchId): mixed
{
return $this->operationTracker?->completeOperation($batchId);
}
public function failBatchOperation(string $batchId, \Throwable $error): mixed
{
return $this->operationTracker?->failOperation($batchId, $error);
}
public function updateBatchOperation(string $batchId, array $data): void
{
$this->operationTracker?->updateOperation($batchId, $data);
}
public function checkMemoryThresholds(string $migrationVersion, int $completedMigrations, int $totalMigrations): void
{
if (! $this->memoryMonitor) {
return;
}
$thresholds = $this->getEffectiveMemoryThresholds();
$memorySummary = $this->memoryMonitor->getSummary();
// Check for critical memory usage that should abort the batch
if ($memorySummary->usagePercentage->getValue() >= $thresholds->abort->getValue()) {
throw MemoryThresholdExceededException::batchAborted(
currentUsage: $memorySummary->usagePercentage,
abortThreshold: $thresholds->abort,
currentMemory: $memorySummary->current,
memoryLimit: $memorySummary->limit,
completedMigrations: $completedMigrations,
totalMigrations: $totalMigrations
);
}
// Warning for high memory usage
if ($memorySummary->usagePercentage->getValue() >= $thresholds->warning->getValue()) {
$this->logger?->warning('Migration memory usage approaching threshold', [
'migration_version' => $migrationVersion,
'memory_usage_percentage' => $memorySummary->usagePercentage->getValue(),
'warning_threshold' => $thresholds->warning->getValue(),
'current_memory' => $memorySummary->getCurrentHumanReadable(),
'memory_limit' => $memorySummary->limit->toHumanReadable(),
'completed_migrations' => $completedMigrations,
'total_migrations' => $totalMigrations,
]);
}
}
public function logPostMigrationMemory(string $migrationVersion, mixed $migrationSnapshot = null): void
{
if (! $this->memoryMonitor || ! $this->logger) {
return;
}
$memorySummary = $this->memoryMonitor->getSummary();
$this->logger->info('Post-migration memory status', [
'migration_version' => $migrationVersion,
'current_memory' => $memorySummary->getCurrentHumanReadable(),
'peak_memory' => $memorySummary->getPeakHumanReadable(),
'memory_usage_percentage' => $memorySummary->getUsagePercentageFormatted(),
'execution_time' => $migrationSnapshot?->duration?->toSeconds(),
'memory_delta' => $migrationSnapshot?->memoryDelta?->toHumanReadable(),
'is_approaching_limit' => $memorySummary->isApproachingLimit,
]);
}
public function logPostRollbackMemory(string $migrationVersion, mixed $rollbackSnapshot = null): void
{
if (! $this->memoryMonitor || ! $this->logger) {
return;
}
$memorySummary = $this->memoryMonitor->getSummary();
$this->logger->info('Post-rollback memory status', [
'migration_version' => $migrationVersion,
'current_memory' => $memorySummary->getCurrentHumanReadable(),
'peak_memory' => $memorySummary->getPeakHumanReadable(),
'memory_usage_percentage' => $memorySummary->getUsagePercentageFormatted(),
'execution_time' => $rollbackSnapshot?->duration?->toSeconds(),
'memory_delta' => $rollbackSnapshot?->memoryDelta?->toHumanReadable(),
'is_approaching_limit' => $memorySummary->isApproachingLimit,
]);
}
private function getEffectiveMemoryThresholds(): MemoryThresholds
{
return $this->memoryThresholds ?? MemoryThresholds::default();
}
}

View File

@@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\Services;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationCollection;
use App\Framework\Database\Migration\MigrationVersionCollection;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
final readonly class MigrationValidator
{
public function __construct(
private ConnectionInterface $connection,
private DatabasePlatform $platform
) {
}
public function runPreFlightChecks(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
$results = [];
$results['connectivity'] = $this->checkDatabaseConnectivity();
$results['permissions'] = $this->checkDatabasePermissions();
$results['schema_state'] = $this->checkSchemaState($migrations, $appliedVersions);
$results['disk_space'] = $this->checkDiskSpace();
$results['backup_readiness'] = $this->checkBackupReadiness();
$results['migration_conflicts'] = $this->checkMigrationConflicts($migrations);
$results['environment_safety'] = $this->checkEnvironmentSafety();
return $results;
}
public function validateMigrationDependencies(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
$orphanedDependencies = [];
$partialChains = $this->findPartialDependencyChains($migrations, $appliedVersions);
$orderingIssues = $this->validateMigrationOrdering($migrations, $appliedVersions);
return [
'orphaned_dependencies' => $orphanedDependencies,
'partial_chains' => $partialChains,
'ordering_issues' => $orderingIssues,
];
}
public function validateRollbackSafety(Migration $migration, string $version): void
{
// Basic rollback safety checks
if (! method_exists($migration, 'down') || ! is_callable([$migration, 'down'])) {
throw FrameworkException::create(
ErrorCode::DB_MIGRATION_FAILED,
"Migration {$version} does not implement rollback functionality"
);
}
// Additional safety checks could be added here
// - Check for data loss operations
// - Validate rollback dependencies
// - Check for irreversible operations
}
private function checkDatabaseConnectivity(): array
{
try {
$result = $this->connection->queryScalar(SqlQuery::create('SELECT 1'));
return [
'status' => 'pass',
'message' => 'Database connection successful',
'details' => ['test_query_result' => $result],
];
} catch (\Throwable $e) {
return [
'status' => 'fail',
'message' => 'Database connection failed',
'details' => ['error' => $e->getMessage()],
'severity' => 'critical',
];
}
}
private function checkDatabasePermissions(): array
{
$permissions = [];
$requiredPermissions = ['CREATE', 'ALTER', 'DROP', 'INSERT', 'UPDATE', 'DELETE', 'SELECT'];
foreach ($requiredPermissions as $permission) {
try {
// Platform-specific permission checks would go here
$permissions[$permission] = true;
} catch (\Throwable $e) {
$permissions[$permission] = false;
}
}
$missingPermissions = array_keys(array_filter($permissions, fn ($has) => ! $has));
return [
'status' => empty($missingPermissions) ? 'pass' : 'fail',
'message' => empty($missingPermissions) ? 'All required permissions available' : 'Missing required permissions',
'details' => [
'permissions' => $permissions,
'missing' => $missingPermissions,
],
'severity' => empty($missingPermissions) ? 'info' : 'critical',
];
}
private function checkSchemaState(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
try {
$expectedTables = $this->extractExpectedTablesFromMigrations($migrations, $appliedVersions);
$actualTables = $this->getActualDatabaseTables();
$missingTables = array_diff($expectedTables, $actualTables);
$unexpectedTables = array_diff($actualTables, $expectedTables);
$status = empty($missingTables) && empty($unexpectedTables) ? 'pass' : 'warning';
return [
'status' => $status,
'message' => 'Schema state analysis completed',
'details' => [
'expected_tables' => $expectedTables,
'actual_tables' => $actualTables,
'missing_tables' => $missingTables,
'unexpected_tables' => $unexpectedTables,
],
'severity' => $status === 'pass' ? 'info' : 'warning',
];
} catch (\Throwable $e) {
return [
'status' => 'fail',
'message' => 'Schema state check failed',
'details' => ['error' => $e->getMessage()],
'severity' => 'warning',
];
}
}
private function checkDiskSpace(): array
{
try {
$freeSpaceBytes = disk_free_space('.');
$totalSpaceBytes = disk_total_space('.');
if ($freeSpaceBytes === false || $totalSpaceBytes === false) {
throw new \RuntimeException('Unable to determine disk space');
}
$freeSpace = Byte::fromBytes($freeSpaceBytes);
$totalSpace = Byte::fromBytes($totalSpaceBytes);
$usagePercentage = $totalSpaceBytes > 0 ? (1 - ($freeSpaceBytes / $totalSpaceBytes)) * 100 : 0;
$status = $usagePercentage < 90 ? 'pass' : ($usagePercentage < 95 ? 'warning' : 'fail');
return [
'status' => $status,
'message' => 'Disk space check completed',
'details' => [
'free_space_bytes' => $freeSpaceBytes,
'total_space_bytes' => $totalSpaceBytes,
'usage_percentage' => round($usagePercentage, 2),
'free_space_human' => $freeSpace->toHumanReadable(),
'total_space_human' => $totalSpace->toHumanReadable(),
],
'severity' => $status === 'pass' ? 'info' : ($status === 'warning' ? 'warning' : 'critical'),
];
} catch (\Throwable $e) {
return [
'status' => 'fail',
'message' => 'Disk space check failed',
'details' => ['error' => $e->getMessage()],
'severity' => 'warning',
];
}
}
private function checkBackupReadiness(): array
{
// This is a placeholder - actual backup systems would be checked here
return [
'status' => 'pass',
'message' => 'Backup readiness not implemented - manual verification required',
'details' => ['recommendation' => 'Ensure database backup is current before proceeding'],
'severity' => 'info',
];
}
private function checkMigrationConflicts(MigrationCollection $migrations): array
{
$versions = [];
$conflicts = [];
foreach ($migrations as $migration) {
$version = $migration->getVersion()->toString();
if (in_array($version, $versions)) {
$conflicts[] = $version;
}
$versions[] = $version;
}
return [
'status' => empty($conflicts) ? 'pass' : 'fail',
'message' => empty($conflicts) ? 'No migration conflicts found' : 'Migration version conflicts detected',
'details' => [
'total_migrations' => count($migrations),
'unique_versions' => count(array_unique($versions)),
'conflicting_versions' => $conflicts,
],
'severity' => empty($conflicts) ? 'info' : 'critical',
];
}
private function checkEnvironmentSafety(): array
{
$environment = $this->getEnvironmentDetails();
$isProduction = in_array($environment['app_env'] ?? 'unknown', ['production', 'prod']);
return [
'status' => 'pass',
'message' => 'Environment safety check completed',
'details' => [
'environment' => $environment,
'is_production' => $isProduction,
'recommendations' => $isProduction ? ['Create backup', 'Schedule maintenance window'] : [],
],
'severity' => $isProduction ? 'warning' : 'info',
];
}
private function findPartialDependencyChains(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
// Placeholder for dependency chain analysis
return [];
}
private function validateMigrationOrdering(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
$issues = [];
$versions = [];
foreach ($migrations as $migration) {
$version = $migration->getVersion()->toString();
$timestamp = $this->extractTimestampFromVersion($version);
$versions[] = ['version' => $version, 'timestamp' => $timestamp];
}
// Sort by timestamp and check if versions are in chronological order
usort($versions, fn ($a, $b) => $a['timestamp'] <=> $b['timestamp']);
$previousTimestamp = 0;
foreach ($versions as $versionInfo) {
if ($versionInfo['timestamp'] < $previousTimestamp) {
$issues[] = [
'version' => $versionInfo['version'],
'issue' => 'Version timestamp is out of chronological order',
'timestamp' => $versionInfo['timestamp'],
];
}
$previousTimestamp = $versionInfo['timestamp'];
}
return $issues;
}
private function extractTimestampFromVersion(string $version): int
{
// Extract timestamp from version format: YYYY_MM_DD_HHMMSS_Description
if (preg_match('/^(\d{4})_(\d{2})_(\d{2})_(\d{6})/', $version, $matches)) {
$year = (int) $matches[1];
$month = (int) $matches[2];
$day = (int) $matches[3];
$time = $matches[4];
$hour = (int) substr($time, 0, 2);
$minute = (int) substr($time, 2, 2);
$second = (int) substr($time, 4, 2);
return mktime($hour, $minute, $second, $month, $day, $year);
}
return 0;
}
private function extractExpectedTablesFromMigrations(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): array
{
// This would analyze migration files to extract expected table names
// For now, return empty array as placeholder
return [];
}
private function getActualDatabaseTables(): array
{
try {
$query = $this->platform->getListTablesSQL();
$result = $this->connection->queryColumn(SqlQuery::create($query));
return $result;
} catch (\Throwable $e) {
return [];
}
}
private function getEnvironmentDetails(): array
{
return [
'app_env' => $_ENV['APP_ENV'] ?? 'unknown',
'php_version' => PHP_VERSION,
'server_name' => $_SERVER['SERVER_NAME'] ?? 'unknown',
'database_driver' => $this->platform->getName(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\ValueObjects;
use App\Framework\Core\ValueObjects\Percentage;
final readonly class MemoryThresholds
{
public function __construct(
public Percentage $warning,
public Percentage $critical,
public Percentage $abort
) {
if ($this->warning->greaterThan($this->critical)) {
throw new \InvalidArgumentException('Warning threshold cannot be higher than critical threshold');
}
if ($this->critical->greaterThan($this->abort)) {
throw new \InvalidArgumentException('Critical threshold cannot be higher than abort threshold');
}
}
public static function default(): self
{
return new self(
warning: Percentage::from(75.0), // 75% - Warning level
critical: Percentage::from(85.0), // 85% - Critical level
abort: Percentage::from(95.0) // 95% - Abort migration
);
}
public static function conservative(): self
{
return new self(
warning: Percentage::from(60.0),
critical: Percentage::from(70.0),
abort: Percentage::from(80.0)
);
}
public static function relaxed(): self
{
return new self(
warning: Percentage::from(80.0),
critical: Percentage::from(90.0),
abort: Percentage::from(98.0)
);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration\ValueObjects;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Configuration for migration table structure and naming
*/
final readonly class MigrationTableConfig
{
public function __construct(
public string $tableName,
public string $versionColumn = 'version',
public string $descriptionColumn = 'description',
public string $executedAtColumn = 'executed_at',
public string $idColumn = 'id'
) {
if (empty($tableName)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
'Migration table name cannot be empty'
)->withData(['table_name' => $tableName]);
}
$this->validateColumnName($versionColumn, 'version');
$this->validateColumnName($descriptionColumn, 'description');
$this->validateColumnName($executedAtColumn, 'executed_at');
$this->validateColumnName($idColumn, 'id');
}
public static function default(): self
{
return new self('migrations');
}
public static function withCustomTable(string $tableName): self
{
return new self($tableName);
}
/**
* Get the full INSERT SQL for recording a migration
*/
public function getInsertSql(): string
{
return "INSERT INTO {$this->tableName} ({$this->versionColumn}, {$this->descriptionColumn}, {$this->executedAtColumn}) VALUES (?, ?, ?)";
}
/**
* Get the SELECT SQL for fetching applied versions
*/
public function getVersionSelectSql(): string
{
return "SELECT {$this->versionColumn} FROM {$this->tableName} ORDER BY {$this->executedAtColumn}";
}
/**
* Get the DELETE SQL for removing a migration record
*/
public function getDeleteSql(): string
{
return "DELETE FROM {$this->tableName} WHERE {$this->versionColumn} = ?";
}
/**
* Get the CREATE TABLE SQL for the given database driver
*/
public function getCreateTableSql(string $driver): string
{
return match($driver) {
'mysql' => "CREATE TABLE IF NOT EXISTS {$this->tableName} (
{$this->idColumn} INT PRIMARY KEY AUTO_INCREMENT,
{$this->versionColumn} VARCHAR(20) NOT NULL UNIQUE COMMENT 'Format: YYYY_MM_DD_HHMMSS',
{$this->descriptionColumn} TEXT,
{$this->executedAtColumn} TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_{$this->versionColumn} ({$this->versionColumn}),
INDEX idx_{$this->executedAtColumn} ({$this->executedAtColumn})
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
'pgsql' => "CREATE TABLE IF NOT EXISTS {$this->tableName} (
{$this->idColumn} SERIAL PRIMARY KEY,
{$this->versionColumn} VARCHAR(20) NOT NULL UNIQUE,
{$this->descriptionColumn} TEXT,
{$this->executedAtColumn} TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
'sqlite' => "CREATE TABLE IF NOT EXISTS {$this->tableName} (
{$this->idColumn} INTEGER PRIMARY KEY AUTOINCREMENT,
{$this->versionColumn} TEXT NOT NULL UNIQUE,
{$this->descriptionColumn} TEXT,
{$this->executedAtColumn} TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
default => throw FrameworkException::create(
ErrorCode::VAL_UNSUPPORTED_OPERATION,
"Unsupported database driver for migrations"
)->withData(['driver' => $driver])
};
}
/**
* Get PostgreSQL index creation SQL (separate from table creation)
*/
public function getPostgreSqlIndexSql(): array
{
return [
"CREATE INDEX IF NOT EXISTS idx_{$this->tableName}_{$this->versionColumn} ON {$this->tableName} ({$this->versionColumn})",
"CREATE INDEX IF NOT EXISTS idx_{$this->tableName}_{$this->executedAtColumn} ON {$this->tableName} ({$this->executedAtColumn})",
];
}
private function validateColumnName(string $columnName, string $context): void
{
if (empty($columnName)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Migration {$context} column name cannot be empty"
)->withData([
'column_name' => $columnName,
'context' => $context,
]);
}
// Basic SQL injection prevention
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $columnName)) {
throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Invalid column name format for migration {$context}"
)->withData([
'column_name' => $columnName,
'context' => $context,
]);
}
}
}

View File

@@ -9,6 +9,7 @@ use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Driver\Optimization\MySQLOptimizer;
use App\Framework\Database\Driver\Optimization\PostgreSQLOptimizer;
use App\Framework\Database\Driver\Optimization\SQLiteOptimizer;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
/**
@@ -107,7 +108,7 @@ final readonly class DatabaseHealthChecker
private function checkConnection(ConnectionInterface $connection): HealthCheckStatus
{
try {
$connection->queryScalar('SELECT 1');
$connection->queryScalar(SqlQuery::create('SELECT 1'));
return HealthCheckStatus::ok('Connection successful');
} catch (\Throwable $e) {
@@ -126,16 +127,16 @@ final readonly class DatabaseHealthChecker
$tableName = "health_check_{$timestamp}";
// Create table
$connection->execute("CREATE TEMPORARY TABLE {$tableName} (id INT, value VARCHAR(50))");
$connection->execute(SqlQuery::create("CREATE TEMPORARY TABLE {$tableName} (id INT, value VARCHAR(50))"));
// Write data
$connection->execute("INSERT INTO {$tableName} (id, value) VALUES (1, 'test')");
$connection->execute(SqlQuery::create("INSERT INTO {$tableName} (id, value) VALUES (1, 'test')"));
// Read data
$result = $connection->queryScalar("SELECT value FROM {$tableName} WHERE id = 1");
$result = $connection->queryScalar(SqlQuery::create("SELECT value FROM {$tableName} WHERE id = 1"));
// Drop table
$connection->execute("DROP TABLE {$tableName}");
$connection->execute(SqlQuery::create("DROP TABLE {$tableName}"));
if ($result === 'test') {
return HealthCheckStatus::ok('Read/write operations successful');
@@ -159,14 +160,14 @@ final readonly class DatabaseHealthChecker
switch ($driver) {
case 'mysql':
$result = $connection->queryOne("
$result = $connection->queryOne(SqlQuery::create("
SELECT
SUM(data_length + index_length) as size
FROM
information_schema.tables
WHERE
table_schema = DATABASE()
");
"));
$size = (int)($result['size'] ?? 0);
$sizeFormatted = $this->formatBytes($size);
@@ -220,11 +221,11 @@ final readonly class DatabaseHealthChecker
switch ($driver) {
case 'mysql':
$result = $connection->queryScalar("
$result = $connection->queryScalar(SqlQuery::create("
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
");
"));
$count = (int)$result;
break;

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\ValueObjects\SqlQuery;
final class PdoConnection implements ConnectionInterface
{
@@ -19,11 +20,11 @@ final class PdoConnection implements ConnectionInterface
#$this->pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
}
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
try {
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
$statement = $this->pdo->prepare($query->sql);
$statement->execute($query->parameters->toPdoArray());
return $statement->rowCount();
} catch (\PDOException $e) {
@@ -31,35 +32,37 @@ final class PdoConnection implements ConnectionInterface
}
}
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
try {
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
$statement = $this->pdo->prepare($query->sql);
$statement->execute($query->parameters->toPdoArray());
return new PdoResult($statement);
} catch (\PDOException $e) {
throw DatabaseException::simple("Failed to execute query: {$e->getMessage()} --- SQL: {$sql} PARAMETERS: {".implode(", ", $parameters)."}", $e);
$debug = $query->toDebugString();
throw DatabaseException::simple("Failed to execute query: {$e->getMessage()} --- Debug: {$debug}", $e);
}
}
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
$result = $this->query($sql, $parameters);
$result = $this->query($query);
return $result->fetch();
}
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
$result = $this->query($sql, $parameters);
$result = $this->query($query);
return $result->fetchColumn();
}
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
$result = $this->query($sql, $parameters);
$result = $this->query($query);
return $result->fetchScalar();
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Performance;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use Psr\Log\LoggerInterface;
/**
* Monitors database queries for N+1 and other performance issues
*/
final class QueryMonitor
{
private array $queryLog = [];
private array $queryPatterns = [];
private int $queryCount = 0;
private float $totalTime = 0.0;
private bool $enabled = true;
/**
* Threshold for detecting N+1 queries
*/
private const N1_THRESHOLD = 3;
/**
* Threshold for slow queries
*/
private readonly Duration $slowQueryThreshold;
public function __construct(
private readonly ?LoggerInterface $logger = null,
?Duration $slowQueryThreshold = null
) {
$this->slowQueryThreshold = $slowQueryThreshold ?? Duration::fromMilliseconds(100);
}
/**
* Logs a query execution
*/
public function logQuery(string $sql, array $params = [], ?Duration $executionTime = null): void
{
if (! $this->enabled) {
return;
}
$executionTime ??= Duration::zero();
$this->queryCount++;
$this->totalTime += $executionTime->toSeconds();
// Normalize query for pattern detection
$pattern = $this->normalizeQuery($sql);
// Track query patterns for N+1 detection
if (! isset($this->queryPatterns[$pattern])) {
$this->queryPatterns[$pattern] = [
'count' => 0,
'total_time' => 0.0,
'first_sql' => $sql,
'params_samples' => [],
];
}
$this->queryPatterns[$pattern]['count']++;
$this->queryPatterns[$pattern]['total_time'] += $executionTime->toSeconds();
// Store sample params for debugging
if (count($this->queryPatterns[$pattern]['params_samples']) < 5) {
$this->queryPatterns[$pattern]['params_samples'][] = $params;
}
// Add to query log
$this->queryLog[] = [
'sql' => $sql,
'params' => $params,
'time' => $executionTime,
'pattern' => $pattern,
'timestamp' => microtime(true),
];
// Check for slow queries
if ($executionTime->greaterThan($this->slowQueryThreshold)) {
$this->handleSlowQuery($sql, $params, $executionTime);
}
// Check for N+1 patterns
if ($this->queryPatterns[$pattern]['count'] >= self::N1_THRESHOLD) {
$this->handlePotentialN1($pattern, $this->queryPatterns[$pattern]);
}
}
/**
* Normalizes a query to detect patterns
*/
private function normalizeQuery(string $sql): string
{
// Remove whitespace variations
$sql = preg_replace('/\s+/', ' ', trim($sql));
// Replace values with placeholders
// Numbers
$sql = preg_replace('/\b\d+\b/', '?', $sql);
// Quoted strings
$sql = preg_replace("/'[^']*'/", '?', $sql);
$sql = preg_replace('/"[^"]*"/', '?', $sql);
// IN clauses with multiple values
$sql = preg_replace('/IN\s*\([^)]+\)/i', 'IN (?)', $sql);
// Remove comments
$sql = preg_replace('/--.*$/', '', $sql);
$sql = preg_replace('/\/\*.*?\*\//', '', $sql);
return strtolower($sql);
}
/**
* Handles detection of slow queries
*/
private function handleSlowQuery(string $sql, array $params, Duration $executionTime): void
{
$message = sprintf(
'Slow query detected (%.2fms): %s',
$executionTime->toMilliseconds(),
$this->truncateSQL($sql)
);
if ($this->logger) {
$this->logger->warning($message, [
'sql' => $sql,
'params' => $params,
'execution_time_ms' => $executionTime->toMilliseconds(),
]);
}
}
/**
* Handles detection of potential N+1 queries
*/
private function handlePotentialN1(string $pattern, array $patternData): void
{
// Only warn once per pattern per request
static $warnedPatterns = [];
if (isset($warnedPatterns[$pattern])) {
return;
}
$warnedPatterns[$pattern] = true;
$message = sprintf(
'Potential N+1 query detected: Pattern executed %d times (%.2fms total)',
$patternData['count'],
$patternData['total_time'] * 1000
);
if ($this->logger) {
$this->logger->warning($message, [
'pattern' => $pattern,
'example_sql' => $patternData['first_sql'],
'count' => $patternData['count'],
'total_time_ms' => $patternData['total_time'] * 1000,
'param_samples' => $patternData['params_samples'],
]);
}
}
/**
* Gets query statistics
*/
public function getStatistics(): QueryStatistics
{
$n1Queries = array_filter($this->queryPatterns, fn ($p) => $p['count'] >= self::N1_THRESHOLD);
$slowQueries = array_filter($this->queryLog, fn ($q) => $q['time']->greaterThan($this->slowQueryThreshold));
return QueryStatistics::fromRawData(
totalQueries: $this->queryCount,
totalTimeSeconds: $this->totalTime,
uniquePatterns: count($this->queryPatterns),
n1QueryPatterns: count($n1Queries),
slowQueries: count($slowQueries),
queryPatterns: $this->queryPatterns,
queryLog: $this->queryLog
);
}
/**
* Resets the monitor
*/
public function reset(): void
{
$this->queryLog = [];
$this->queryPatterns = [];
$this->queryCount = 0;
$this->totalTime = 0.0;
}
/**
* Enables or disables monitoring
*/
public function setEnabled(bool $enabled): void
{
$this->enabled = $enabled;
}
/**
* Checks if monitoring is enabled
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Truncates SQL for display
*/
private function truncateSQL(string $sql, int $maxLength = 100): string
{
if (strlen($sql) <= $maxLength) {
return $sql;
}
return substr($sql, 0, $maxLength) . '...';
}
/**
* Analyzes queries and returns recommendations
*/
public function getRecommendations(): array
{
$recommendations = [];
$stats = $this->getStatistics();
// Check for N+1 queries
if ($stats->n1QueryPatterns > 0) {
$n1Patterns = array_filter($this->queryPatterns, fn ($p) => $p['count'] >= self::N1_THRESHOLD);
foreach ($n1Patterns as $pattern => $data) {
$recommendations[] = [
'type' => 'n1_query',
'severity' => 'high',
'message' => sprintf(
'N+1 Query Pattern: "%s" executed %d times. Consider using batch loading with findWithRelations().',
$this->truncateSQL($data['first_sql']),
$data['count']
),
'impact' => sprintf('%.2fms total time', $data['total_time'] * 1000),
'solution' => 'Use EntityManager::findWithRelations() or BatchLoader to preload relations',
];
}
}
// Check for slow queries
$slowQueries = array_filter($this->queryLog, fn ($q) => $q['time']->greaterThan($this->slowQueryThreshold));
foreach ($slowQueries as $query) {
$recommendations[] = [
'type' => 'slow_query',
'severity' => 'medium',
'message' => sprintf(
'Slow Query: "%s" took %.2fms',
$this->truncateSQL($query['sql']),
$query['time']->toMilliseconds()
),
'impact' => 'Performance degradation',
'solution' => 'Consider adding indexes or optimizing the query',
];
}
// Check for too many queries
if ($stats->totalQueries > 50) {
$recommendations[] = [
'type' => 'too_many_queries',
'severity' => 'medium',
'message' => sprintf('%d queries executed in this request', $stats->totalQueries),
'impact' => sprintf('%.2fms total database time', $stats->totalTime->toMilliseconds()),
'solution' => 'Consider caching or batch loading to reduce query count',
];
}
return $recommendations;
}
/**
* Throws exception if critical issues detected (for dev mode)
*/
public function assertNoPerformanceIssues(): void
{
$stats = $this->getStatistics();
if ($stats->n1QueryPatterns > 0) {
$n1Patterns = array_filter($this->queryPatterns, fn ($p) => $p['count'] >= self::N1_THRESHOLD);
$examples = array_map(fn ($p) => $p['first_sql'], array_slice($n1Patterns, 0, 3));
throw FrameworkException::create(
ErrorCode::DB_PERFORMANCE_ISSUE,
sprintf(
'N+1 Query detected: %d patterns found. Examples: %s',
$stats->n1QueryPatterns,
implode('; ', $examples)
)
);
}
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Performance;
use App\Framework\Core\ValueObjects\Duration;
/**
* Value Object für Query-Statistiken
*/
final readonly class QueryStatistics
{
public function __construct(
public int $totalQueries,
public Duration $totalTime,
public Duration $averageTime,
public int $uniquePatterns,
public int $n1QueryPatterns,
public int $slowQueries,
public array $queryPatterns,
public array $queryLog
) {
}
/**
* Factory method für die Erstellung aus rohen Daten
*/
public static function fromRawData(
int $totalQueries,
float $totalTimeSeconds,
int $uniquePatterns,
int $n1QueryPatterns,
int $slowQueries,
array $queryPatterns,
array $queryLog
): self {
$totalTime = Duration::fromSeconds($totalTimeSeconds);
$averageTime = $totalQueries > 0
? Duration::fromSeconds($totalTimeSeconds / $totalQueries)
: Duration::zero();
return new self(
totalQueries: $totalQueries,
totalTime: $totalTime,
averageTime: $averageTime,
uniquePatterns: $uniquePatterns,
n1QueryPatterns: $n1QueryPatterns,
slowQueries: $slowQueries,
queryPatterns: $queryPatterns,
queryLog: $queryLog
);
}
/**
* Gibt die Statistiken als Array zurück
*/
public function toArray(): array
{
return [
'total_queries' => $this->totalQueries,
'total_time_ms' => $this->totalTime->toMilliseconds(),
'average_time_ms' => $this->averageTime->toMilliseconds(),
'unique_patterns' => $this->uniquePatterns,
'n1_query_patterns' => $this->n1QueryPatterns,
'slow_queries' => $this->slowQueries,
'patterns' => array_map(function ($pattern) {
$patternTotalTime = Duration::fromSeconds($pattern['total_time']);
$patternAverageTime = Duration::fromSeconds($pattern['total_time'] / $pattern['count']);
return [
'count' => $pattern['count'],
'total_time_ms' => $patternTotalTime->toMilliseconds(),
'average_time_ms' => $patternAverageTime->toMilliseconds(),
'example' => $pattern['first_sql'] ?? '',
];
}, array_slice($this->queryPatterns, 0, 10)), // Top 10 patterns
];
}
/**
* Prüft ob Performance-Probleme vorliegen
*/
public function hasPerformanceIssues(): bool
{
return $this->n1QueryPatterns > 0 ||
$this->slowQueries > 0 ||
$this->totalQueries > 100;
}
/**
* Gibt eine lesbare Zusammenfassung zurück
*/
public function getSummary(): string
{
$summary = sprintf(
"Queries: %d | Time: %.2fms | Avg: %.2fms",
$this->totalQueries,
$this->totalTime->toMilliseconds(),
$this->averageTime->toMilliseconds()
);
if ($this->n1QueryPatterns > 0) {
$summary .= sprintf(" | N+1: %d patterns", $this->n1QueryPatterns);
}
if ($this->slowQueries > 0) {
$summary .= sprintf(" | Slow: %d", $this->slowQueries);
}
return $summary;
}
/**
* Prüft ob die Gesamtzeit ein bestimmtes Limit überschreitet
*/
public function exceedsTimeLimit(Duration $limit): bool
{
return $this->totalTime->greaterThan($limit);
}
/**
* Prüft ob die durchschnittliche Query-Zeit ein Limit überschreitet
*/
public function averageExceedsLimit(Duration $limit): bool
{
return $this->averageTime->greaterThan($limit);
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform;
use App\Framework\Database\Platform\ValueObjects\ColumnDefinition;
use App\Framework\Database\Platform\ValueObjects\TableOptions;
/**
* Database platform abstraction for SQL dialect differences
*/
interface DatabasePlatform
{
/**
* Get the SQL for auto-increment primary key
*/
public function getAutoIncrementSQL(): string;
/**
* Get the SQL data type for a given column type
*/
public function getColumnTypeSQL(string $type, array $options = []): string;
/**
* Get the SQL to create a table
*
* @param string $table Table name
* @param ColumnDefinition[] $columns Array of column definitions
* @param TableOptions|array $options Table options or legacy array options
*/
public function getCreateTableSQL(string $table, array $columns, array|TableOptions $options = []): string;
/**
* Get the SQL to drop a table
*/
public function getDropTableSQL(string $table, bool $ifExists = false): string;
/**
* Get the SQL to add an index
*/
public function getCreateIndexSQL(string $table, string $indexName, array $columns, array $options = []): string;
/**
* Get the SQL to check if a table exists
*/
public function getTableExistsSQL(string $table): string;
/**
* Get the SQL to list all tables
*/
public function getListTablesSQL(): string;
/**
* Quote an identifier (table or column name)
*/
public function quoteIdentifier(string $identifier): string;
/**
* Get current timestamp expression
*/
public function getCurrentTimestampSQL(): string;
/**
* Get the name of the platform
*/
public function getName(): string;
/**
* Check if the platform supports a feature
*/
public function supportsFeature(string $feature): bool;
/**
* Get SQL for UUID/ULID storage
*/
public function getBinaryUuidSQL(): string;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform;
use App\Framework\Config\Environment;
use App\Framework\DI\Initializer;
/**
* Initializes the database platform based on environment configuration
*/
final readonly class DatabasePlatformInitializer
{
public function __construct(
private Environment $environment
) {
}
#[Initializer]
public function __invoke(): DatabasePlatform
{
$driver = $this->environment->get('DB_DRIVER', 'mysql');
return match($driver) {
'mysql', 'mysqli' => new MySQLPlatform(),
'pgsql', 'postgres', 'postgresql' => throw new \RuntimeException('PostgreSQL platform not yet implemented'),
'sqlite' => throw new \RuntimeException('SQLite platform not yet implemented'),
default => throw new \RuntimeException("Unsupported database driver: {$driver}")
};
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform\Enums;
/**
* Database column types abstraction
*/
enum ColumnType: string
{
// Numeric types
case TINY_INTEGER = 'tinyint';
case SMALL_INTEGER = 'smallint';
case INTEGER = 'integer';
case BIG_INTEGER = 'bigint';
case DECIMAL = 'decimal';
case FLOAT = 'float';
case DOUBLE = 'double';
case BOOLEAN = 'boolean';
// String types
case CHAR = 'char';
case VARCHAR = 'varchar';
case TEXT = 'text';
case MEDIUM_TEXT = 'mediumtext';
case LONG_TEXT = 'longtext';
// Binary types
case BINARY = 'binary';
case VARBINARY = 'varbinary';
case BLOB = 'blob';
case MEDIUM_BLOB = 'mediumblob';
case LONG_BLOB = 'longblob';
// Date/Time types
case DATE = 'date';
case TIME = 'time';
case DATETIME = 'datetime';
case TIMESTAMP = 'timestamp';
case YEAR = 'year';
// JSON type
case JSON = 'json';
// UUID types
case UUID = 'uuid';
case ULID = 'ulid';
public function requiresLength(): bool
{
return match($this) {
self::CHAR, self::VARCHAR, self::BINARY, self::VARBINARY => true,
default => false
};
}
public function requiresPrecision(): bool
{
return match($this) {
self::DECIMAL, self::FLOAT, self::DOUBLE => true,
default => false
};
}
public function isNumeric(): bool
{
return match($this) {
self::TINY_INTEGER, self::SMALL_INTEGER, self::INTEGER, self::BIG_INTEGER,
self::DECIMAL, self::FLOAT, self::DOUBLE, self::BOOLEAN => true,
default => false
};
}
public function isTextual(): bool
{
return match($this) {
self::CHAR, self::VARCHAR, self::TEXT, self::MEDIUM_TEXT, self::LONG_TEXT, self::JSON => true,
default => false
};
}
public function isBinary(): bool
{
return match($this) {
self::BINARY, self::VARBINARY, self::BLOB, self::MEDIUM_BLOB, self::LONG_BLOB,
self::UUID, self::ULID => true,
default => false
};
}
public function isTemporal(): bool
{
return match($this) {
self::DATE, self::TIME, self::DATETIME, self::TIMESTAMP, self::YEAR => true,
default => false
};
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform\Enums;
/**
* Database features that platforms may or may not support
*/
enum DatabaseFeature: string
{
case AUTO_INCREMENT = 'auto_increment';
case FOREIGN_KEYS = 'foreign_keys';
case TRANSACTIONS = 'transactions';
case SAVEPOINTS = 'savepoints';
case JSON_COLUMNS = 'json_columns';
case FULLTEXT_SEARCH = 'fulltext_search';
case SPATIAL_INDEXES = 'spatial_indexes';
case PARTITIONING = 'partitioning';
case VIEWS = 'views';
case STORED_PROCEDURES = 'stored_procedures';
case TRIGGERS = 'triggers';
case UUID_GENERATION = 'uuid_generation';
case ULID_GENERATION = 'ulid_generation';
case CONCURRENT_INDEXES = 'concurrent_indexes';
case PARTIAL_INDEXES = 'partial_indexes';
case EXPRESSION_INDEXES = 'expression_indexes';
case RECURSIVE_CTE = 'recursive_cte';
case WINDOW_FUNCTIONS = 'window_functions';
case UPSERT = 'upsert';
case RETURNING_CLAUSE = 'returning_clause';
public function getDescription(): string
{
return match($this) {
self::AUTO_INCREMENT => 'Automatic incremental primary keys',
self::FOREIGN_KEYS => 'Foreign key constraints',
self::TRANSACTIONS => 'ACID transaction support',
self::SAVEPOINTS => 'Transaction savepoint support',
self::JSON_COLUMNS => 'Native JSON column type',
self::FULLTEXT_SEARCH => 'Full-text search indexes',
self::SPATIAL_INDEXES => 'Spatial/geometric indexes',
self::PARTITIONING => 'Table partitioning',
self::VIEWS => 'Database views',
self::STORED_PROCEDURES => 'Stored procedures and functions',
self::TRIGGERS => 'Database triggers',
self::UUID_GENERATION => 'Built-in UUID generation',
self::ULID_GENERATION => 'Built-in ULID generation',
self::CONCURRENT_INDEXES => 'Non-blocking index creation',
self::PARTIAL_INDEXES => 'Indexes with WHERE conditions',
self::EXPRESSION_INDEXES => 'Indexes on expressions',
self::RECURSIVE_CTE => 'Recursive common table expressions',
self::WINDOW_FUNCTIONS => 'Window/analytic functions',
self::UPSERT => 'INSERT ON DUPLICATE KEY UPDATE',
self::RETURNING_CLAUSE => 'RETURNING clause support'
};
}
public function isAdvancedFeature(): bool
{
return match($this) {
self::PARTITIONING, self::RECURSIVE_CTE, self::WINDOW_FUNCTIONS,
self::EXPRESSION_INDEXES, self::CONCURRENT_INDEXES => true,
default => false
};
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform\Enums;
/**
* Database index types
*/
enum IndexType: string
{
case PRIMARY = 'primary';
case UNIQUE = 'unique';
case INDEX = 'index';
case FULLTEXT = 'fulltext';
case SPATIAL = 'spatial';
public function isUnique(): bool
{
return match($this) {
self::PRIMARY, self::UNIQUE => true,
default => false
};
}
public function requiresSpecialEngine(): bool
{
return match($this) {
self::FULLTEXT, self::SPATIAL => true,
default => false
};
}
public function getKeyword(): string
{
return match($this) {
self::PRIMARY => 'PRIMARY KEY',
self::UNIQUE => 'UNIQUE KEY',
self::INDEX => 'KEY',
self::FULLTEXT => 'FULLTEXT KEY',
self::SPATIAL => 'SPATIAL KEY'
};
}
}

View File

@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform;
use App\Framework\Database\Platform\Enums\ColumnType;
use App\Framework\Database\Platform\Enums\DatabaseFeature;
use App\Framework\Database\Platform\Enums\IndexType;
use App\Framework\Database\Platform\ValueObjects\ColumnDefinition;
use App\Framework\Database\Platform\ValueObjects\IndexDefinition;
use App\Framework\Database\Platform\ValueObjects\TableOptions;
/**
* MySQL/MariaDB platform implementation
*/
final readonly class MySQLPlatform implements DatabasePlatform
{
private array $supportedFeatures;
public function __construct()
{
$this->supportedFeatures = [
DatabaseFeature::AUTO_INCREMENT->value => true,
DatabaseFeature::FOREIGN_KEYS->value => true,
DatabaseFeature::TRANSACTIONS->value => true,
DatabaseFeature::SAVEPOINTS->value => true,
DatabaseFeature::JSON_COLUMNS->value => true,
DatabaseFeature::FULLTEXT_SEARCH->value => true,
DatabaseFeature::SPATIAL_INDEXES->value => true,
DatabaseFeature::PARTITIONING->value => true,
DatabaseFeature::VIEWS->value => true,
DatabaseFeature::STORED_PROCEDURES->value => true,
DatabaseFeature::TRIGGERS->value => true,
DatabaseFeature::UUID_GENERATION->value => false,
DatabaseFeature::ULID_GENERATION->value => false,
DatabaseFeature::CONCURRENT_INDEXES->value => false,
DatabaseFeature::PARTIAL_INDEXES->value => false,
DatabaseFeature::EXPRESSION_INDEXES->value => true,
DatabaseFeature::RECURSIVE_CTE->value => true,
DatabaseFeature::WINDOW_FUNCTIONS->value => true,
DatabaseFeature::UPSERT->value => true,
DatabaseFeature::RETURNING_CLAUSE->value => false,
];
}
public function getName(): string
{
return 'MySQL';
}
public function supportsFeature(string $feature): bool
{
return $this->supportedFeatures[$feature] ?? false;
}
public function getAutoIncrementSQL(): string
{
return 'AUTO_INCREMENT';
}
public function getColumnTypeSQL(string $type, array $options = []): string
{
$columnType = ColumnType::tryFrom($type);
if (! $columnType) {
throw new \InvalidArgumentException("Unknown column type: {$type}");
}
return match($columnType) {
ColumnType::TINY_INTEGER => $this->buildNumericType('TINYINT', $options),
ColumnType::SMALL_INTEGER => $this->buildNumericType('SMALLINT', $options),
ColumnType::INTEGER => $this->buildNumericType('INT', $options),
ColumnType::BIG_INTEGER => $this->buildNumericType('BIGINT', $options),
ColumnType::DECIMAL => $this->buildDecimalType('DECIMAL', $options),
ColumnType::FLOAT => $this->buildDecimalType('FLOAT', $options),
ColumnType::DOUBLE => $this->buildDecimalType('DOUBLE', $options),
ColumnType::BOOLEAN => 'BOOLEAN',
ColumnType::CHAR => $this->buildStringType('CHAR', $options),
ColumnType::VARCHAR => $this->buildStringType('VARCHAR', $options),
ColumnType::TEXT => 'TEXT',
ColumnType::MEDIUM_TEXT => 'MEDIUMTEXT',
ColumnType::LONG_TEXT => 'LONGTEXT',
ColumnType::BINARY => $this->buildBinaryType('BINARY', $options),
ColumnType::VARBINARY => $this->buildBinaryType('VARBINARY', $options),
ColumnType::BLOB => 'BLOB',
ColumnType::MEDIUM_BLOB => 'MEDIUMBLOB',
ColumnType::LONG_BLOB => 'LONGBLOB',
ColumnType::DATE => 'DATE',
ColumnType::TIME => 'TIME',
ColumnType::DATETIME => 'DATETIME',
ColumnType::TIMESTAMP => 'TIMESTAMP',
ColumnType::YEAR => 'YEAR',
ColumnType::JSON => 'JSON',
ColumnType::UUID => 'BINARY(16)',
ColumnType::ULID => 'BINARY(16)',
};
}
public function getCreateTableSQL(string $table, array $columns, array|TableOptions $options = []): string
{
$tableOptions = $options instanceof TableOptions ? $options : TableOptions::default();
$sql = [];
// Table creation start
if ($tableOptions->temporary) {
$sql[] = 'CREATE TEMPORARY TABLE';
} else {
$sql[] = 'CREATE TABLE';
}
if ($tableOptions->ifNotExists) {
$sql[] = 'IF NOT EXISTS';
}
$sql[] = $this->quoteIdentifier($table);
// Column definitions
$columnDefinitions = [];
$indexes = [];
foreach ($columns as $column) {
if ($column instanceof ColumnDefinition) {
$columnDefinitions[] = $this->buildColumnDefinition($column);
// Auto-create primary key index
if ($column->isPrimaryKey()) {
$indexes[] = IndexDefinition::primary('PRIMARY', [$column->name]);
}
// Auto-create unique index for columns with unique option
$options = $column->getOptions();
if ($options->unique) {
$indexName = 'UNQ_' . $table . '_' . $column->name;
$indexes[] = IndexDefinition::unique($indexName, [$column->name], $table);
}
} else {
throw new \InvalidArgumentException('All columns must be ColumnDefinition instances');
}
}
// Index definitions
$indexDefinitions = [];
foreach ($indexes as $index) {
if ($index instanceof IndexDefinition) {
$indexDefinitions[] = $this->buildIndexDefinition($index);
}
}
// Combine column and index definitions
$allDefinitions = array_merge($columnDefinitions, $indexDefinitions);
$sql[] = '(' . implode(', ', $allDefinitions) . ')';
// Table options
$tableOptionsClauses = [];
if ($tableOptions->engine) {
$tableOptionsClauses[] = "ENGINE = {$tableOptions->engine}";
}
if ($tableOptions->charset) {
$tableOptionsClauses[] = "DEFAULT CHARACTER SET = {$tableOptions->charset}";
}
if ($tableOptions->collation) {
$tableOptionsClauses[] = "COLLATE = {$tableOptions->collation}";
}
if ($tableOptions->comment) {
$tableOptionsClauses[] = "COMMENT = " . $this->escapeString($tableOptions->comment);
}
if ($tableOptions->autoIncrementStart !== null) {
$tableOptionsClauses[] = "AUTO_INCREMENT = {$tableOptions->autoIncrementStart}";
}
if (! empty($tableOptionsClauses)) {
$sql[] = implode(' ', $tableOptionsClauses);
}
return implode(' ', $sql);
}
public function getDropTableSQL(string $table, bool $ifExists = false): string
{
$sql = 'DROP TABLE';
if ($ifExists) {
$sql .= ' IF EXISTS';
}
$sql .= ' ' . $this->quoteIdentifier($table);
return $sql;
}
public function getCreateIndexSQL(string $table, string $indexName, array $columns, array $options = []): string
{
$indexType = $options['type'] ?? IndexType::INDEX;
if (is_string($indexType)) {
$indexType = IndexType::from($indexType);
}
$index = new IndexDefinition($indexName, $indexType, $columns, $table, $options);
return "CREATE {$indexType->getKeyword()} {$this->quoteIdentifier($indexName)} ON {$this->quoteIdentifier($table)} ({$this->quoteColumnList($columns)})";
}
public function getTableExistsSQL(string $table): string
{
return "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = " . $this->escapeString($table);
}
public function getListTablesSQL(): string
{
return "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY table_name";
}
public function quoteIdentifier(string $identifier): string
{
return '`' . str_replace('`', '``', $identifier) . '`';
}
public function getCurrentTimestampSQL(): string
{
return 'CURRENT_TIMESTAMP';
}
public function getBinaryUuidSQL(): string
{
// MySQL doesn't have built-in UUID generation, use application-level UUID generation
return 'UNHEX(REPLACE(UUID(), \'-\', \'\'))';
}
private function buildColumnDefinition(ColumnDefinition $column): string
{
$parts = [];
// Column name and type
$parts[] = $this->quoteIdentifier($column->name);
$parts[] = $this->getColumnTypeSQL($column->type->value, [
'length' => $column->length,
'precision' => $column->precision,
'scale' => $column->scale,
'unsigned' => $column->unsigned,
]);
// Character set and collation for text columns
if ($column->type->isTextual() && $column->type !== ColumnType::JSON) {
if ($column->charset !== 'utf8mb4') {
$parts[] = "CHARACTER SET {$column->charset}";
}
if ($column->collation !== 'utf8mb4_unicode_ci') {
$parts[] = "COLLATE {$column->collation}";
}
}
// Nullability
$parts[] = $column->nullable ? 'NULL' : 'NOT NULL';
// Auto increment
if ($column->autoIncrement) {
$parts[] = 'AUTO_INCREMENT';
}
// Default value
if ($column->hasDefault()) {
if (is_string($column->default) && ! in_array($column->default, ['CURRENT_TIMESTAMP', 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'])) {
$parts[] = 'DEFAULT ' . $this->escapeString($column->default);
} else {
$parts[] = "DEFAULT {$column->default}";
}
}
// Comment
if ($column->comment) {
$parts[] = 'COMMENT ' . $this->escapeString($column->comment);
}
return implode(' ', $parts);
}
private function buildIndexDefinition(IndexDefinition $index): string
{
$parts = [];
// Index type keyword
$parts[] = $index->type->getKeyword();
// Index name (except for PRIMARY KEY)
if ($index->type !== IndexType::PRIMARY) {
$parts[] = $this->quoteIdentifier($index->name);
}
// Column list
$parts[] = '(' . $this->quoteColumnList($index->columns) . ')';
return implode(' ', $parts);
}
private function buildNumericType(string $baseType, array $options): string
{
$type = $baseType;
if (! empty($options['length'])) {
$type .= "({$options['length']})";
}
if (! empty($options['unsigned'])) {
$type .= ' UNSIGNED';
}
return $type;
}
private function buildDecimalType(string $baseType, array $options): string
{
$type = $baseType;
if (! empty($options['precision']) && ! empty($options['scale'])) {
$type .= "({$options['precision']},{$options['scale']})";
} elseif (! empty($options['precision'])) {
$type .= "({$options['precision']})";
}
if (! empty($options['unsigned'])) {
$type .= ' UNSIGNED';
}
return $type;
}
private function buildStringType(string $baseType, array $options): string
{
$type = $baseType;
if (! empty($options['length'])) {
$type .= "({$options['length']})";
} else {
$type .= '(255)'; // Default length
}
return $type;
}
private function buildBinaryType(string $baseType, array $options): string
{
$type = $baseType;
if (! empty($options['length'])) {
$type .= "({$options['length']})";
}
return $type;
}
private function quoteColumnList(array $columns): string
{
return implode(', ', array_map([$this, 'quoteIdentifier'], $columns));
}
private function escapeString(string $value): string
{
return "'" . str_replace("'", "''", $value) . "'";
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Platform\ValueObjects\ColumnDefinition;
use App\Framework\Database\Platform\ValueObjects\TableOptions;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Fluent schema builder for database-independent table creation
*/
final readonly class SchemaBuilder
{
public function __construct(
private ConnectionInterface $connection,
private DatabasePlatform $platform
) {
}
/**
* Create a new table
*
* @param string $tableName
* @param ColumnDefinition[] $columns
* @param TableOptions $options
*/
public function createTable(string $tableName, array $columns, ?TableOptions $options = null): void
{
$options = $options ?? TableOptions::default();
$sql = $this->platform->getCreateTableSQL($tableName, $columns, $options);
$this->connection->query(SqlQuery::create($sql));
}
/**
* Drop a table
*/
public function dropTable(string $tableName, bool $ifExists = false): void
{
$sql = $this->platform->getDropTableSQL($tableName, $ifExists);
$this->connection->query(SqlQuery::create($sql));
}
/**
* Create an auto-increment primary key column
*/
public function id(string $name = 'id'): ColumnDefinition
{
return ColumnDefinition::id($name);
}
/**
* Create a string/varchar column
*/
public function string(string $name, int $length = 255): ColumnDefinition
{
return ColumnDefinition::string($name, $length);
}
/**
* Create a text column
*/
public function text(string $name): ColumnDefinition
{
return ColumnDefinition::text($name);
}
/**
* Create an integer column
*/
public function integer(string $name): ColumnDefinition
{
return ColumnDefinition::integer($name);
}
/**
* Create a big integer column
*/
public function bigInteger(string $name): ColumnDefinition
{
return ColumnDefinition::bigInteger($name);
}
/**
* Create a decimal column
*/
public function decimal(string $name, int $precision = 8, int $scale = 2): ColumnDefinition
{
return ColumnDefinition::decimal($name, $precision, $scale);
}
/**
* Create a float column
*/
public function float(string $name): ColumnDefinition
{
return ColumnDefinition::float($name);
}
/**
* Create a boolean column
*/
public function boolean(string $name): ColumnDefinition
{
return ColumnDefinition::boolean($name);
}
/**
* Create a date column
*/
public function date(string $name): ColumnDefinition
{
return ColumnDefinition::date($name);
}
/**
* Create a datetime column
*/
public function datetime(string $name): ColumnDefinition
{
return ColumnDefinition::datetime($name);
}
/**
* Create a timestamp column
*/
public function timestamp(string $name): ColumnDefinition
{
return ColumnDefinition::timestamp($name);
}
/**
* Create a binary column
*/
public function binary(string $name, int $length): ColumnDefinition
{
return ColumnDefinition::binary($name, $length);
}
/**
* Create a JSON column
*/
public function json(string $name): ColumnDefinition
{
return ColumnDefinition::json($name);
}
/**
* Create a time column
*/
public function time(string $name): ColumnDefinition
{
return ColumnDefinition::time($name);
}
/**
* Create a year column
*/
public function year(string $name): ColumnDefinition
{
return ColumnDefinition::year($name);
}
/**
* Create timestamps columns (created_at, updated_at)
*
* @return ColumnDefinition[]
*/
public function timestamps(): array
{
return [
ColumnDefinition::timestamp('created_at', false),
ColumnDefinition::timestamp('updated_at', false),
];
}
/**
* Create soft delete column (deleted_at)
*/
public function softDeletes(): ColumnDefinition
{
return ColumnDefinition::timestamp('deleted_at', true);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform;
use App\Framework\Database\ConnectionInterface;
/**
* Factory for creating SchemaBuilder instances with appropriate platform
*/
final readonly class SchemaBuilderFactory
{
public static function create(ConnectionInterface $connection): SchemaBuilder
{
// For now, we'll use MySQL platform
// In a future version, this could detect the database type or accept it as parameter
$platform = new MySQLPlatform();
return new SchemaBuilder($connection, $platform);
}
public static function createForPlatform(ConnectionInterface $connection, DatabasePlatform $platform): SchemaBuilder
{
return new SchemaBuilder($connection, $platform);
}
}

View File

@@ -0,0 +1,315 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform\ValueObjects;
use App\Framework\Database\Platform\Enums\ColumnType;
/**
* Represents a database column definition
*/
final readonly class ColumnDefinition
{
public function __construct(
public string $name,
public ColumnType $type,
public ?int $length = null,
public ?int $precision = null,
public ?int $scale = null,
public bool $nullable = true,
public mixed $default = null,
public bool $autoIncrement = false,
public bool $unsigned = false,
public string $charset = 'utf8mb4',
public string $collation = 'utf8mb4_unicode_ci',
public ?string $comment = null,
public ?ColumnOptions $options = null
) {
if ($type->requiresLength() && $length === null) {
throw new \InvalidArgumentException("Column type {$type->value} requires a length");
}
if ($type->requiresPrecision() && $precision === null) {
throw new \InvalidArgumentException("Column type {$type->value} requires precision");
}
if ($autoIncrement && ! $type->isNumeric()) {
throw new \InvalidArgumentException("Auto-increment is only valid for numeric column types");
}
if ($unsigned && ! $type->isNumeric()) {
throw new \InvalidArgumentException("Unsigned attribute is only valid for numeric column types");
}
}
public function getOptions(): ColumnOptions
{
return $this->options ?? ColumnOptions::default();
}
public static function id(string $name = 'id'): self
{
return new self(
name: $name,
type: ColumnType::BIG_INTEGER,
nullable: false,
autoIncrement: true,
unsigned: true
);
}
public static function string(string $name, int $length = 255, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::VARCHAR,
length: $length,
nullable: $nullable
);
}
public static function text(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::TEXT,
nullable: $nullable
);
}
public static function integer(string $name, bool $nullable = true, bool $unsigned = false): self
{
return new self(
name: $name,
type: ColumnType::INTEGER,
nullable: $nullable,
unsigned: $unsigned
);
}
public static function bigInteger(string $name, bool $nullable = true, bool $unsigned = false): self
{
return new self(
name: $name,
type: ColumnType::BIG_INTEGER,
nullable: $nullable,
unsigned: $unsigned
);
}
public static function decimal(string $name, int $precision = 8, int $scale = 2, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::DECIMAL,
precision: $precision,
scale: $scale,
nullable: $nullable
);
}
public static function boolean(string $name, bool $nullable = true, ?bool $default = null): self
{
return new self(
name: $name,
type: ColumnType::BOOLEAN,
nullable: $nullable,
default: $default
);
}
public static function timestamp(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::TIMESTAMP,
nullable: $nullable
);
}
public static function timestamps(): array
{
return [
new self(
name: 'created_at',
type: ColumnType::TIMESTAMP,
nullable: false,
default: 'CURRENT_TIMESTAMP'
),
new self(
name: 'updated_at',
type: ColumnType::TIMESTAMP,
nullable: false,
default: 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
),
];
}
public static function json(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::JSON,
nullable: $nullable
);
}
public static function uuid(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::UUID,
nullable: $nullable
);
}
public static function binary(string $name, int $length, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::BINARY,
length: $length,
nullable: $nullable
);
}
public static function date(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::DATE,
nullable: $nullable
);
}
public static function time(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::TIME,
nullable: $nullable
);
}
public static function datetime(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::DATETIME,
nullable: $nullable
);
}
public static function year(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::YEAR,
nullable: $nullable
);
}
public static function float(string $name, bool $nullable = true): self
{
return new self(
name: $name,
type: ColumnType::FLOAT,
nullable: $nullable
);
}
public function withDefault(mixed $value): self
{
return new self(
name: $this->name,
type: $this->type,
length: $this->length,
precision: $this->precision,
scale: $this->scale,
nullable: $this->nullable,
default: $value,
autoIncrement: $this->autoIncrement,
unsigned: $this->unsigned,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
options: $this->options
);
}
public function withComment(string $comment): self
{
return new self(
name: $this->name,
type: $this->type,
length: $this->length,
precision: $this->precision,
scale: $this->scale,
nullable: $this->nullable,
default: $this->default,
autoIncrement: $this->autoIncrement,
unsigned: $this->unsigned,
charset: $this->charset,
collation: $this->collation,
comment: $comment,
options: $this->options
);
}
public function notNull(): self
{
return $this->notNullable();
}
public function notNullable(): self
{
return new self(
name: $this->name,
type: $this->type,
length: $this->length,
precision: $this->precision,
scale: $this->scale,
nullable: false,
default: $this->default,
autoIncrement: $this->autoIncrement,
unsigned: $this->unsigned,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
options: $this->options
);
}
public function unique(): self
{
$currentOptions = $this->getOptions();
$newOptions = $currentOptions->withUnique(true);
return new self(
name: $this->name,
type: $this->type,
length: $this->length,
precision: $this->precision,
scale: $this->scale,
nullable: $this->nullable,
default: $this->default,
autoIncrement: $this->autoIncrement,
unsigned: $this->unsigned,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
options: $newOptions
);
}
public function hasDefault(): bool
{
return $this->default !== null;
}
public function isPrimaryKey(): bool
{
return $this->autoIncrement && $this->type->isNumeric();
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform\ValueObjects;
/**
* Value object for column options like unique, index, etc.
*/
final readonly class ColumnOptions
{
public function __construct(
public bool $unique = false,
public bool $index = false,
public ?string $after = null,
public ?string $before = null,
public bool $first = false,
public array $customOptions = []
) {
}
public static function default(): self
{
return new self();
}
public static function unique(): self
{
return new self(unique: true);
}
public static function indexed(): self
{
return new self(index: true);
}
public function withUnique(bool $unique = true): self
{
return new self(
unique: $unique,
index: $this->index,
after: $this->after,
before: $this->before,
first: $this->first,
customOptions: $this->customOptions
);
}
public function withIndex(bool $index = true): self
{
return new self(
unique: $this->unique,
index: $index,
after: $this->after,
before: $this->before,
first: $this->first,
customOptions: $this->customOptions
);
}
public function after(string $columnName): self
{
return new self(
unique: $this->unique,
index: $this->index,
after: $columnName,
before: null,
first: false,
customOptions: $this->customOptions
);
}
public function before(string $columnName): self
{
return new self(
unique: $this->unique,
index: $this->index,
after: null,
before: $columnName,
first: false,
customOptions: $this->customOptions
);
}
public function first(): self
{
return new self(
unique: $this->unique,
index: $this->index,
after: null,
before: null,
first: true,
customOptions: $this->customOptions
);
}
public function withCustomOption(string $key, mixed $value): self
{
$newCustomOptions = $this->customOptions;
$newCustomOptions[$key] = $value;
return new self(
unique: $this->unique,
index: $this->index,
after: $this->after,
before: $this->before,
first: $this->first,
customOptions: $newCustomOptions
);
}
public function hasCustomOption(string $key): bool
{
return array_key_exists($key, $this->customOptions);
}
public function getCustomOption(string $key, mixed $default = null): mixed
{
return $this->customOptions[$key] ?? $default;
}
/**
* Convert to legacy array format for backwards compatibility
*/
public function toArray(): array
{
$array = [
'unique' => $this->unique,
'index' => $this->index,
];
if ($this->after) {
$array['after'] = $this->after;
}
if ($this->before) {
$array['before'] = $this->before;
}
if ($this->first) {
$array['first'] = $this->first;
}
return array_merge($array, $this->customOptions);
}
/**
* Create from legacy array format
*/
public static function fromArray(array $options): self
{
$customOptions = $options;
unset($customOptions['unique'], $customOptions['index'], $customOptions['after'], $customOptions['before'], $customOptions['first']);
return new self(
unique: $options['unique'] ?? false,
index: $options['index'] ?? false,
after: $options['after'] ?? null,
before: $options['before'] ?? null,
first: $options['first'] ?? false,
customOptions: $customOptions
);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform\ValueObjects;
use App\Framework\Database\Platform\Enums\IndexType;
/**
* Represents a database index definition
*/
final readonly class IndexDefinition
{
public function __construct(
public string $name,
public IndexType $type,
public array $columns,
public ?string $tableName = null,
public array $options = []
) {
if (empty($columns)) {
throw new \InvalidArgumentException('Index must have at least one column');
}
foreach ($columns as $column) {
if (! is_string($column) || trim($column) === '') {
throw new \InvalidArgumentException('All index columns must be non-empty strings');
}
}
}
public static function primary(string $name = 'PRIMARY', array $columns = ['id']): self
{
return new self($name, IndexType::PRIMARY, $columns);
}
public static function unique(string $name, array $columns): self
{
return new self($name, IndexType::UNIQUE, $columns);
}
public static function index(string $name, array $columns): self
{
return new self($name, IndexType::INDEX, $columns);
}
public static function fulltext(string $name, array $columns): self
{
return new self($name, IndexType::FULLTEXT, $columns);
}
public static function spatial(string $name, array $columns): self
{
return new self($name, IndexType::SPATIAL, $columns);
}
public function withTable(string $tableName): self
{
return new self(
name: $this->name,
type: $this->type,
columns: $this->columns,
tableName: $tableName,
options: $this->options
);
}
public function withOptions(array $options): self
{
return new self(
name: $this->name,
type: $this->type,
columns: $this->columns,
tableName: $this->tableName,
options: array_merge($this->options, $options)
);
}
public function getFullName(): string
{
if ($this->tableName) {
return "{$this->tableName}_{$this->name}";
}
return $this->name;
}
public function isComposite(): bool
{
return count($this->columns) > 1;
}
public function getFirstColumn(): string
{
return $this->columns[0];
}
public function getColumnsString(): string
{
return implode(', ', $this->columns);
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Platform\ValueObjects;
/**
* Represents table creation options
*/
final readonly class TableOptions
{
public function __construct(
public string $engine = 'InnoDB',
public string $charset = 'utf8mb4',
public string $collation = 'utf8mb4_unicode_ci',
public ?string $comment = null,
public ?int $autoIncrementStart = null,
public bool $temporary = false,
public bool $ifNotExists = true,
public array $partitioning = [],
public array $customOptions = []
) {
}
public static function default(): self
{
return new self();
}
public static function temporary(): self
{
return new self(temporary: true);
}
public static function myISAM(): self
{
return new self(engine: 'MyISAM');
}
public static function memory(): self
{
return new self(engine: 'MEMORY');
}
public function withComment(string $comment): self
{
return new self(
engine: $this->engine,
charset: $this->charset,
collation: $this->collation,
comment: $comment,
autoIncrementStart: $this->autoIncrementStart,
temporary: $this->temporary,
ifNotExists: $this->ifNotExists,
partitioning: $this->partitioning,
customOptions: $this->customOptions
);
}
public function withCharset(string $charset, ?string $collation = null): self
{
return new self(
engine: $this->engine,
charset: $charset,
collation: $collation ?? $this->collation,
comment: $this->comment,
autoIncrementStart: $this->autoIncrementStart,
temporary: $this->temporary,
ifNotExists: $this->ifNotExists,
partitioning: $this->partitioning,
customOptions: $this->customOptions
);
}
public function withCollation(string $collation): self
{
return new self(
engine: $this->engine,
charset: $this->charset,
collation: $collation,
comment: $this->comment,
autoIncrementStart: $this->autoIncrementStart,
temporary: $this->temporary,
ifNotExists: $this->ifNotExists,
partitioning: $this->partitioning,
customOptions: $this->customOptions
);
}
public function withEngine(string $engine): self
{
return new self(
engine: $engine,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
autoIncrementStart: $this->autoIncrementStart,
temporary: $this->temporary,
ifNotExists: $this->ifNotExists,
partitioning: $this->partitioning,
customOptions: $this->customOptions
);
}
public function withIfNotExists(bool $ifNotExists = true): self
{
return new self(
engine: $this->engine,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
autoIncrementStart: $this->autoIncrementStart,
temporary: $this->temporary,
ifNotExists: $ifNotExists,
partitioning: $this->partitioning,
customOptions: $this->customOptions
);
}
public function withAutoIncrementStart(int $start): self
{
return new self(
engine: $this->engine,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
autoIncrementStart: $start,
temporary: $this->temporary,
ifNotExists: $this->ifNotExists,
partitioning: $this->partitioning,
customOptions: $this->customOptions
);
}
public function withoutIfNotExists(): self
{
return new self(
engine: $this->engine,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
autoIncrementStart: $this->autoIncrementStart,
temporary: $this->temporary,
ifNotExists: false,
partitioning: $this->partitioning,
customOptions: $this->customOptions
);
}
public function withCustomOption(string $key, mixed $value): self
{
return new self(
engine: $this->engine,
charset: $this->charset,
collation: $this->collation,
comment: $this->comment,
autoIncrementStart: $this->autoIncrementStart,
temporary: $this->temporary,
ifNotExists: $this->ifNotExists,
partitioning: $this->partitioning,
customOptions: array_merge($this->customOptions, [$key => $value])
);
}
public function isUtf8(): bool
{
return str_starts_with($this->charset, 'utf8');
}
public function supportsFulltext(): bool
{
return in_array($this->engine, ['InnoDB', 'MyISAM']);
}
public function supportsTransactions(): bool
{
return $this->engine === 'InnoDB';
}
public function supportsRowLocking(): bool
{
return $this->engine === 'InnoDB';
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Represents a connection wrapper in a connection pool.
*
@@ -28,13 +30,13 @@ final class PooledConnection implements ConnectionInterface
$this->id = $id;
}
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
$startTime = microtime(true);
$successful = false;
try {
$result = $this->connection->execute($sql, $parameters);
$result = $this->connection->execute($query);
$successful = true;
return $result;
@@ -44,13 +46,13 @@ final class PooledConnection implements ConnectionInterface
}
}
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
$startTime = microtime(true);
$successful = false;
try {
$result = $this->connection->query($sql, $parameters);
$result = $this->connection->query($query);
$successful = true;
return $result;
@@ -60,13 +62,13 @@ final class PooledConnection implements ConnectionInterface
}
}
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
$startTime = microtime(true);
$successful = false;
try {
$result = $this->connection->queryOne($sql, $parameters);
$result = $this->connection->queryOne($query);
$successful = true;
return $result;
@@ -76,13 +78,13 @@ final class PooledConnection implements ConnectionInterface
}
}
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
$startTime = microtime(true);
$successful = false;
try {
$result = $this->connection->queryColumn($sql, $parameters);
$result = $this->connection->queryColumn($query);
$successful = true;
return $result;
@@ -92,13 +94,13 @@ final class PooledConnection implements ConnectionInterface
}
}
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
$startTime = microtime(true);
$successful = false;
try {
$result = $this->connection->queryScalar($sql, $parameters);
$result = $this->connection->queryScalar($query);
$successful = true;
return $result;

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Database\Profiling;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Connection wrapper that adds profiling capabilities to any database connection
@@ -24,16 +25,15 @@ final class ProfilingConnection implements ConnectionInterface
/**
* Execute SQL with profiling
*/
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
if (! $this->profilingEnabled) {
return $this->connection->execute($sql, $parameters);
return $this->connection->execute($query);
}
$profile = $this->profiler->profile(
$sql,
$parameters,
fn () => $this->connection->execute($sql, $parameters)
$query,
fn () => $this->connection->execute($query)
);
$this->logger?->logQuery($profile);
@@ -44,18 +44,17 @@ final class ProfilingConnection implements ConnectionInterface
/**
* Query with profiling
*/
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
if (! $this->profilingEnabled) {
return $this->connection->query($sql, $parameters);
return $this->connection->query($query);
}
$result = null;
$profile = $this->profiler->profile(
$sql,
$parameters,
function () use ($sql, $parameters, &$result) {
$result = $this->connection->query($sql, $parameters);
$query,
function () use ($query, &$result) {
$result = $this->connection->query($query);
return $result;
}
@@ -69,18 +68,17 @@ final class ProfilingConnection implements ConnectionInterface
/**
* Query single row with profiling
*/
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
if (! $this->profilingEnabled) {
return $this->connection->queryOne($sql, $parameters);
return $this->connection->queryOne($query);
}
$result = null;
$profile = $this->profiler->profile(
$sql,
$parameters,
function () use ($sql, $parameters, &$result) {
$result = $this->connection->queryOne($sql, $parameters);
$query,
function () use ($query, &$result) {
$result = $this->connection->queryOne($query);
return $result;
}
@@ -94,18 +92,17 @@ final class ProfilingConnection implements ConnectionInterface
/**
* Query column with profiling
*/
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
if (! $this->profilingEnabled) {
return $this->connection->queryColumn($sql, $parameters);
return $this->connection->queryColumn($query);
}
$result = null;
$profile = $this->profiler->profile(
$sql,
$parameters,
function () use ($sql, $parameters, &$result) {
$result = $this->connection->queryColumn($sql, $parameters);
$query,
function () use ($query, &$result) {
$result = $this->connection->queryColumn($query);
return $result;
}
@@ -119,18 +116,17 @@ final class ProfilingConnection implements ConnectionInterface
/**
* Query scalar with profiling
*/
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
if (! $this->profilingEnabled) {
return $this->connection->queryScalar($sql, $parameters);
return $this->connection->queryScalar($query);
}
$result = null;
$profile = $this->profiler->profile(
$sql,
$parameters,
function () use ($sql, $parameters, &$result) {
$result = $this->connection->queryScalar($sql, $parameters);
$query,
function () use ($query, &$result) {
$result = $this->connection->queryScalar($query);
return $result;
}
@@ -147,9 +143,9 @@ final class ProfilingConnection implements ConnectionInterface
public function beginTransaction(): void
{
if ($this->profilingEnabled && $this->logger) {
$query = SqlQuery::create('BEGIN TRANSACTION');
$profile = $this->profiler->profile(
'BEGIN TRANSACTION',
[],
$query,
fn () => $this->connection->beginTransaction()
);
@@ -165,9 +161,9 @@ final class ProfilingConnection implements ConnectionInterface
public function commit(): void
{
if ($this->profilingEnabled && $this->logger) {
$query = SqlQuery::create('COMMIT');
$profile = $this->profiler->profile(
'COMMIT',
[],
$query,
fn () => $this->connection->commit()
);
@@ -183,9 +179,9 @@ final class ProfilingConnection implements ConnectionInterface
public function rollback(): void
{
if ($this->profilingEnabled && $this->logger) {
$query = SqlQuery::create('ROLLBACK');
$profile = $this->profiler->profile(
'ROLLBACK',
[],
$query,
fn () => $this->connection->rollback()
);

View File

@@ -21,7 +21,7 @@ final class QueryAnalyzer
*/
public function analyzeQuery(QueryProfile $profile): QueryAnalysis
{
$sql = $profile->sql;
$sql = $profile->query->sql;
$suggestions = [];
$issues = [];
$indexRecommendations = [];

View File

@@ -133,7 +133,7 @@ final class QueryLogger
{
$context = [
'profile_id' => $profile->id,
'sql' => $profile->sql,
'sql' => $profile->query->sql,
'normalized_sql' => $profile->getNormalizedSql(),
'execution_time_ms' => $profile->executionTime->toMilliseconds(),
'memory_usage_bytes' => $profile->memoryUsage,
@@ -143,8 +143,8 @@ final class QueryLogger
'complexity_score' => $profile->getComplexityScore(),
];
if ($this->logParameters && ! empty($profile->parameters)) {
$context['parameters'] = $this->sanitizeParameters($profile->parameters);
if ($this->logParameters && ! $profile->query->parameters->isEmpty()) {
$context['parameters'] = $this->sanitizeParameters($profile->query->parameters->toArray());
}
if ($profile->affectedRows !== null) {
@@ -295,7 +295,7 @@ final class QueryLogger
$csv .= sprintf(
"%s,\"%s\",%s,%s,%s,%s,%s,\"%s\"\n",
$profile->id,
str_replace('"', '""', $profile->sql),
str_replace('"', '""', $profile->query->sql),
$profile->getQueryType(),
$profile->executionTime->toMilliseconds(),
$profile->memoryUsage,
@@ -343,7 +343,7 @@ final class QueryLogger
$profile->startTimestamp->format('H:i:s.u'),
strtoupper($profile->getQueryType()),
$profile->executionTime->toMilliseconds(),
htmlspecialchars(substr($profile->sql, 0, 100) . '...'),
htmlspecialchars(substr($profile->query->sql, 0, 100) . '...'),
$profile->getMemoryUsageBytes()->toHumanReadable(),
$profile->affectedRows ?? '-'
);
@@ -376,7 +376,7 @@ final class QueryLogger
$sql .= "-- ERROR: {$profile->error}\n";
}
$sql .= $profile->sql . ";\n\n";
$sql .= $profile->query->sql . ";\n\n";
}
return $sql;

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Database\Profiling;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\Events\Timestamp;
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Immutable query profile with execution metrics
@@ -15,8 +16,7 @@ final readonly class QueryProfile
{
public function __construct(
public string $id,
public string $sql,
public array $parameters,
public SqlQuery $query,
public Duration $executionTime,
public Timestamp $startTimestamp,
public Timestamp $endTimestamp,
@@ -33,7 +33,7 @@ final readonly class QueryProfile
*/
public function getQueryType(): string
{
$sql = trim(strtoupper($this->sql));
$sql = trim(strtoupper($this->query->sql));
$writeOperations = ['INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'REPLACE'];
foreach ($writeOperations as $operation) {
@@ -107,7 +107,7 @@ final readonly class QueryProfile
public function getNormalizedSql(): string
{
// Remove extra whitespace and normalize
$normalized = preg_replace('/\s+/', ' ', trim($this->sql));
$normalized = preg_replace('/\s+/', ' ', trim($this->query->sql));
// Replace parameter placeholders and values with ? for grouping
$normalized = preg_replace('/\$\d+/', '?', $normalized);
@@ -121,7 +121,7 @@ final readonly class QueryProfile
*/
public function getComplexityScore(): int
{
$sql = strtoupper($this->sql);
$sql = strtoupper($this->query->sql);
$score = 1;
// Count JOINs
@@ -172,9 +172,9 @@ final readonly class QueryProfile
{
return [
'id' => $this->id,
'sql' => $this->sql,
'sql' => $this->query->sql,
'normalized_sql' => $this->getNormalizedSql(),
'parameters' => $this->parameters,
'parameters' => $this->query->parameters->toArray(),
'query_type' => $this->getQueryType(),
'execution_time_ms' => $this->executionTime->toMilliseconds(),
'execution_time_seconds' => $this->executionTime->toSeconds(),

View File

@@ -6,6 +6,7 @@ namespace App\Framework\Database\Profiling;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\Events\Timestamp;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
use App\Framework\Performance\MemoryMonitor;
@@ -30,13 +31,12 @@ final class QueryProfiler
/**
* Start profiling a query
*/
public function startProfile(string $sql, array $parameters = []): string
public function startProfile(SqlQuery $query): string
{
$profileId = 'profile_' . ++$this->profileCounter . '_' . uniqid();
$this->activeProfiles[$profileId] = [
'sql' => $sql,
'parameters' => $parameters,
'query' => $query,
'start_time' => $this->clock->time(),
'start_timestamp' => Timestamp::fromClock($this->clock),
'memory_start' => $this->memoryMonitor->getCurrentMemory()->toBytes(),
@@ -65,8 +65,7 @@ final class QueryProfiler
$profile = new QueryProfile(
id: $profileId,
sql: $activeProfile['sql'],
parameters: $activeProfile['parameters'],
query: $activeProfile['query'],
executionTime: Duration::fromSeconds($executionTime),
startTimestamp: $activeProfile['start_timestamp'],
endTimestamp: $endTimestamp,
@@ -86,9 +85,9 @@ final class QueryProfiler
/**
* Profile a callable with automatic timing
*/
public function profile(string $sql, array $parameters, callable $execution): QueryProfile
public function profile(SqlQuery $query, callable $execution): QueryProfile
{
$profileId = $this->startProfile($sql, $parameters);
$profileId = $this->startProfile($query);
try {
$result = $execution();

View File

@@ -196,7 +196,7 @@ final class SlowQueryDetector
*/
private function detectNPlusOnePattern(QueryProfile $profile): bool
{
$sql = strtoupper($profile->sql);
$sql = strtoupper($profile->query->sql);
// Simple heuristic: SELECT with single WHERE condition executed frequently
if (str_starts_with($sql, 'SELECT') &&
@@ -214,7 +214,7 @@ final class SlowQueryDetector
*/
private function detectMissingIndexPattern(QueryProfile $profile): bool
{
$sql = strtoupper($profile->sql);
$sql = strtoupper($profile->query->sql);
// Heuristic: SELECT with WHERE but no JOINs, taking long time
return str_starts_with($sql, 'SELECT') &&
@@ -228,7 +228,7 @@ final class SlowQueryDetector
*/
private function detectTableScanPattern(QueryProfile $profile): bool
{
$sql = strtoupper($profile->sql);
$sql = strtoupper($profile->query->sql);
// Heuristic: SELECT without WHERE clause or with very generic conditions
return str_starts_with($sql, 'SELECT') &&

View File

@@ -8,6 +8,7 @@ use App\Framework\Database\Config\ReadWriteConfig;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\ReadWrite\MasterSlaveRouter;
use App\Framework\Database\ReadWrite\ReplicationLagDetector;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\Clock;
/**
@@ -53,39 +54,39 @@ final class ReadWriteConnection implements ConnectionInterface
}
}
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
$this->forceWrite = true;
return $this->writeConnection->execute($sql, $parameters);
return $this->writeConnection->execute($query);
}
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
$connection = $this->getConnection($sql);
$connection = $this->getConnection($query->sql);
return $connection->query($sql, $parameters);
return $connection->query($query);
}
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
$connection = $this->getConnection($sql);
$connection = $this->getConnection($query->sql);
return $connection->queryOne($sql, $parameters);
return $connection->queryOne($query);
}
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
$connection = $this->getConnection($sql);
$connection = $this->getConnection($query->sql);
return $connection->queryColumn($sql, $parameters);
return $connection->queryColumn($query);
}
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
$connection = $this->getConnection($sql);
$connection = $this->getConnection($query->sql);
return $connection->queryScalar($sql, $parameters);
return $connection->queryScalar($query);
}
public function beginTransaction(): void

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Repository;
use App\Framework\Database\EntityManager;
/**
* Service für Batch Loading Operationen (Komposition statt Vererbung)
*/
final readonly class BatchLoader
{
public function __construct(
private EntityManager $entityManager
) {
}
/**
* Findet Entities mit Batch Loading von Relations (verhindert N+1 Queries)
*
* @param string $entityClass
* @param array<string, mixed> $criteria
* @param array<string> $relations Relations die vorgeladen werden sollen
* @param array<string, string>|null $orderBy
* @param int|null $limit
* @return array<int, object>
*/
public function findWithRelations(
string $entityClass,
array $criteria = [],
array $relations = [],
?array $orderBy = null,
?int $limit = null
): array {
return $this->entityManager->findWithRelations(
$entityClass,
$criteria,
$relations,
$orderBy,
$limit
);
}
/**
* Findet paginierte Entities mit optionalem Batch Loading
*
* @param string $entityClass
* @param int $page Aktuelle Seite (1-basiert)
* @param int $limit Items pro Seite
* @param array<string, mixed> $criteria
* @param array<string> $relations Relations die vorgeladen werden sollen
* @param array<string, string>|null $orderBy
* @return PaginatedResult
*/
public function findPaginated(
string $entityClass,
int $page = 1,
int $limit = 20,
array $criteria = [],
array $relations = [],
?array $orderBy = null
): PaginatedResult {
if ($page < 1) {
$page = 1;
}
if ($limit < 1) {
$limit = 20;
}
// Berechne Offset
$offset = ($page - 1) * $limit;
// Hole Total Count für Pagination Info
$totalItems = $this->countBy($entityClass, $criteria);
// Hole die Items mit Batch Loading wenn Relations angegeben
if (! empty($relations)) {
// Verwende findWithRelations für Batch Loading
$items = $this->entityManager->findWithRelations(
$entityClass,
$criteria,
$relations,
$orderBy,
$limit
);
// EntityManager unterstützt kein Offset in findWithRelations,
// daher müssen wir manuell slicen (TODO: Optimierung auf DB-Ebene)
if ($offset > 0) {
$allItems = $this->entityManager->findWithRelations(
$entityClass,
$criteria,
$relations,
$orderBy,
null
);
$items = array_slice($allItems, $offset, $limit);
}
} else {
// Verwende normales findBy mit Limit/Offset
$items = $this->entityManager->findBy($entityClass, $criteria, $orderBy, $limit);
// Auch hier müssen wir manuell slicen
if ($offset > 0) {
$allItems = $this->entityManager->findBy($entityClass, $criteria, $orderBy);
$items = array_slice($allItems, $offset, $limit);
}
}
return PaginatedResult::fromQuery(
items: $items,
totalItems: $totalItems,
page: $page,
limit: $limit
);
}
/**
* Zählt Entities nach Kriterien
*
* @param string $entityClass
* @param array<string, mixed> $criteria
* @return int
*/
public function countBy(string $entityClass, array $criteria = []): int
{
// TODO: Optimierung - sollte COUNT Query verwenden statt alle zu laden
$items = $this->entityManager->findBy($entityClass, $criteria);
return count($items);
}
/**
* Batch-Save für mehrere Entities mit optimierter Performance
*
* @param array<int, object> $entities
* @param int $batchSize Anzahl Entities pro Batch
* @return array<int, object>
*/
public function saveBatch(array $entities, int $batchSize = 100): array
{
$saved = [];
// Verwende Transaktion für bessere Performance
$this->entityManager->transaction(function () use ($entities, $batchSize, &$saved) {
foreach (array_chunk($entities, $batchSize) as $batch) {
$saved = array_merge($saved, $this->entityManager->saveAll(...$batch));
}
});
return $saved;
}
/**
* Batch-Delete für mehrere Entities
*
* @param array<int, object> $entities
* @param int $batchSize Anzahl Entities pro Batch
*/
public function deleteBatch(array $entities, int $batchSize = 100): void
{
$this->entityManager->transaction(function () use ($entities, $batchSize) {
foreach (array_chunk($entities, $batchSize) as $batch) {
foreach ($batch as $entity) {
$this->entityManager->delete($entity);
}
}
});
}
}

View File

@@ -7,52 +7,51 @@ namespace App\Framework\Database\Repository;
use App\Framework\Database\EntityManager;
/**
* Basis-Repository für Entities
* Base Repository Service für Entities (Komposition statt Vererbung)
* Wird als Dependency in Domain Repositories injiziert
*/
abstract class EntityRepository
final readonly class EntityRepository
{
protected string $entityClass;
private BatchLoader $batchLoader;
public function __construct(
protected EntityManager $entityManager
private EntityManager $entityManager
) {
if (! isset($this->entityClass)) {
throw new \LogicException(static::class . " must define \$entityClass property");
}
$this->batchLoader = new BatchLoader($entityManager);
}
/**
* Findet Entity nach ID
*/
public function find(string $id): ?object
public function find(string $entityClass, string $id): ?object
{
return $this->entityManager->find($this->entityClass, $id);
return $this->entityManager->find($entityClass, $id);
}
/**
* Findet Entity nach ID (eager loading)
*/
public function findEager(string $id): ?object
public function findEager(string $entityClass, string $id): ?object
{
return $this->entityManager->findEager($this->entityClass, $id);
return $this->entityManager->findEager($entityClass, $id);
}
/**
* Findet alle Entities
* @return array<int, object>
*/
public function findAll(): array
public function findAll(string $entityClass): array
{
return $this->entityManager->findAll($this->entityClass);
return $this->entityManager->findAll($entityClass);
}
/**
* Findet alle Entities (eager loading)
* @return array<int, object>
*/
public function findAllEager(): array
public function findAllEager(string $entityClass): array
{
return $this->entityManager->findAllEager($this->entityClass);
return $this->entityManager->findAllEager($entityClass);
}
/**
@@ -61,18 +60,18 @@ abstract class EntityRepository
* @param array<string, string>|null $orderBy
* @return array<int, object>
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null): array
public function findBy(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
return $this->entityManager->findBy($this->entityClass, $criteria, $orderBy, $limit);
return $this->entityManager->findBy($entityClass, $criteria, $orderBy, $limit);
}
/**
* Findet eine Entity nach Kriterien
* @param array<string, mixed> $criteria
*/
public function findOneBy(array $criteria): ?object
public function findOneBy(string $entityClass, array $criteria): ?object
{
return $this->entityManager->findOneBy($this->entityClass, $criteria);
return $this->entityManager->findOneBy($entityClass, $criteria);
}
/**
@@ -108,4 +107,102 @@ abstract class EntityRepository
{
return $this->entityManager->transaction($callback);
}
/**
* Findet Entities mit Batch Loading von Relations (verhindert N+1 Queries)
*
* @param string $entityClass
* @param array<string, mixed> $criteria
* @param array<string> $relations Relations die vorgeladen werden sollen
* @param array<string, string>|null $orderBy
* @param int|null $limit
* @return array<int, object>
*/
public function findWithRelations(
string $entityClass,
array $criteria = [],
array $relations = [],
?array $orderBy = null,
?int $limit = null
): array {
return $this->batchLoader->findWithRelations(
$entityClass,
$criteria,
$relations,
$orderBy,
$limit
);
}
/**
* Findet paginierte Entities mit optionalem Batch Loading
*
* @param string $entityClass
* @param int $page Aktuelle Seite (1-basiert)
* @param int $limit Items pro Seite
* @param array<string, mixed> $criteria
* @param array<string> $relations Relations die vorgeladen werden sollen
* @param array<string, string>|null $orderBy
* @return PaginatedResult
*/
public function findPaginated(
string $entityClass,
int $page = 1,
int $limit = 20,
array $criteria = [],
array $relations = [],
?array $orderBy = null
): PaginatedResult {
return $this->batchLoader->findPaginated(
$entityClass,
$page,
$limit,
$criteria,
$relations,
$orderBy
);
}
/**
* Zählt Entities nach Kriterien
*
* @param string $entityClass
* @param array<string, mixed> $criteria
* @return int
*/
public function countBy(string $entityClass, array $criteria = []): int
{
return $this->batchLoader->countBy($entityClass, $criteria);
}
/**
* Batch-Save für mehrere Entities mit optimierter Performance
*
* @param array<int, object> $entities
* @param int $batchSize Anzahl Entities pro Batch
* @return array<int, object>
*/
public function saveBatch(array $entities, int $batchSize = 100): array
{
return $this->batchLoader->saveBatch($entities, $batchSize);
}
/**
* Batch-Delete für mehrere Entities
*
* @param array<int, object> $entities
* @param int $batchSize Anzahl Entities pro Batch
*/
public function deleteBatch(array $entities, int $batchSize = 100): void
{
$this->batchLoader->deleteBatch($entities, $batchSize);
}
/**
* Gibt den BatchLoader für erweiterte Operationen zurück
*/
public function getBatchLoader(): BatchLoader
{
return $this->batchLoader;
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Repository;
/**
* Value Object für paginierte Ergebnisse
*/
final readonly class PaginatedResult
{
public function __construct(
public array $items,
public int $totalItems,
public int $currentPage,
public int $itemsPerPage,
public int $totalPages
) {
if ($currentPage < 1) {
throw new \InvalidArgumentException('Current page must be at least 1');
}
if ($itemsPerPage < 1) {
throw new \InvalidArgumentException('Items per page must be at least 1');
}
if ($totalItems < 0) {
throw new \InvalidArgumentException('Total items cannot be negative');
}
if ($totalPages < 0) {
throw new \InvalidArgumentException('Total pages cannot be negative');
}
}
/**
* Factory method für die Erstellung aus Query-Daten
*/
public static function fromQuery(
array $items,
int $totalItems,
int $page,
int $limit
): self {
$totalPages = (int) ceil($totalItems / $limit);
return new self(
items: $items,
totalItems: $totalItems,
currentPage: $page,
itemsPerPage: $limit,
totalPages: $totalPages
);
}
/**
* Prüft ob es eine nächste Seite gibt
*/
public function hasNextPage(): bool
{
return $this->currentPage < $this->totalPages;
}
/**
* Prüft ob es eine vorherige Seite gibt
*/
public function hasPreviousPage(): bool
{
return $this->currentPage > 1;
}
/**
* Berechnet den Offset für die aktuelle Seite
*/
public function getOffset(): int
{
return ($this->currentPage - 1) * $this->itemsPerPage;
}
/**
* Gibt die Range der angezeigten Items zurück (z.B. "1-10 of 100")
*/
public function getDisplayRange(): string
{
if ($this->totalItems === 0) {
return '0-0 of 0';
}
$start = $this->getOffset() + 1;
$end = min($this->currentPage * $this->itemsPerPage, $this->totalItems);
return "{$start}-{$end} of {$this->totalItems}";
}
/**
* Konvertiert zu Array für JSON-Serialisierung
*/
public function toArray(): array
{
return [
'items' => $this->items,
'pagination' => [
'total_items' => $this->totalItems,
'current_page' => $this->currentPage,
'items_per_page' => $this->itemsPerPage,
'total_pages' => $this->totalPages,
'has_next_page' => $this->hasNextPage(),
'has_previous_page' => $this->hasPreviousPage(),
'display_range' => $this->getDisplayRange(),
],
];
}
}

View File

@@ -35,6 +35,8 @@ final class Blueprint
public bool $temporary = false;
public bool $ifNotExists = false;
public function __construct(string $table)
{
$this->table = $table;
@@ -71,6 +73,13 @@ final class Blueprint
return $this;
}
public function ifNotExists(): self
{
$this->ifNotExists = true;
return $this;
}
/**
* Column definitions
*/
@@ -91,12 +100,12 @@ final class Blueprint
public function increments(string $column): ColumnDefinition
{
return $this->addColumn('increments', $column);
return $this->addColumn('increments', $column)->autoIncrement()->primary();
}
public function bigIncrements(string $column): ColumnDefinition
{
return $this->addColumn('bigIncrements', $column);
return $this->addColumn('bigIncrements', $column)->autoIncrement()->primary();
}
public function integer(string $column): ColumnDefinition

View File

@@ -441,7 +441,7 @@ final class {$className} extends AbstractMigration
{
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromString('{$version->toString()}');
return MigrationVersion::fromTimestamp('{$version->toString()}');
}
public function getDescription(): string

View File

@@ -43,6 +43,10 @@ final class MySQLSchemaCompiler implements SchemaCompiler
$sql .= " TEMPORARY";
}
if ($blueprint->ifNotExists) {
$sql .= " IF NOT EXISTS";
}
$sql .= " `{$command->table}` (";
// Columns
@@ -125,8 +129,8 @@ final class MySQLSchemaCompiler implements SchemaCompiler
private function getColumnType(ColumnDefinition $column): string
{
return match($column->type) {
'increments' => 'INT AUTO_INCREMENT',
'bigIncrements' => 'BIGINT AUTO_INCREMENT',
'increments' => 'INT',
'bigIncrements' => 'BIGINT',
'integer' => 'INT',
'bigInteger' => 'BIGINT',
'tinyInteger' => 'TINYINT',

View File

@@ -43,7 +43,13 @@ final class PostgreSQLSchemaCompiler implements SchemaCompiler
$sql .= " TEMPORARY";
}
$sql .= " TABLE \"{$command->table}\" (";
$sql .= " TABLE";
if ($blueprint->ifNotExists) {
$sql .= " IF NOT EXISTS";
}
$sql .= " \"{$command->table}\" (";
// Columns
$columns = [];

View File

@@ -43,7 +43,13 @@ final class SQLiteSchemaCompiler implements SchemaCompiler
$sql .= " TEMPORARY";
}
$sql .= " TABLE `{$command->table}` (";
$sql .= " TABLE";
if ($blueprint->ifNotExists) {
$sql .= " IF NOT EXISTS";
}
$sql .= " `{$command->table}` (";
// Columns
$columns = [];

View File

@@ -11,6 +11,7 @@ use App\Framework\Database\Schema\Commands\{
DropTableCommand,
RenameTableCommand
};
use App\Framework\Database\ValueObjects\SqlQuery;
/**
* Database schema builder for migrations
@@ -28,10 +29,28 @@ final class Schema
* Create a new table
* @param string $table
* @param callable(Blueprint): void $callback
* @param bool $ifNotExists - Default true for idempotent migrations
*/
public function create(string $table, callable $callback): void
public function create(string $table, callable $callback, bool $ifNotExists = true): void
{
$blueprint = new Blueprint($table);
if ($ifNotExists) {
$blueprint->ifNotExists();
}
$callback($blueprint);
$this->commands[] = new CreateTableCommand($table, $blueprint);
}
/**
* Create a new table with IF NOT EXISTS
* @param string $table
* @param callable(Blueprint): void $callback
*/
public function createIfNotExists(string $table, callable $callback): void
{
$blueprint = new Blueprint($table);
$blueprint->ifNotExists();
$callback($blueprint);
$this->commands[] = new CreateTableCommand($table, $blueprint);
@@ -132,17 +151,30 @@ final class Schema
*/
public function execute(): void
{
$compiler = $this->createCompiler();
if (empty($this->commands)) {
return;
}
foreach ($this->commands as $command) {
$sql = $compiler->compile($command);
$statements = $this->toSql();
if (is_array($sql)) {
foreach ($sql as $statement) {
$this->connection->execute($statement);
// If we're already in a transaction (e.g., from MigrationRunner),
// execute statements directly without managing our own transaction
if ($this->connection->inTransaction()) {
foreach ($statements as $statement) {
$this->connection->execute(SqlQuery::create($statement));
}
} else {
// We're not in a transaction, so manage our own
$this->connection->beginTransaction();
try {
foreach ($statements as $statement) {
$this->connection->execute(SqlQuery::create($statement));
}
} else {
$this->connection->execute($sql);
$this->connection->commit();
} catch (\Throwable $e) {
$this->connection->rollback();
throw $e;
}
}

View File

@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Schema;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Platform\DatabasePlatform;
use App\Framework\Database\Platform\ValueObjects\ColumnDefinition;
use App\Framework\Database\Platform\ValueObjects\IndexDefinition;
use App\Framework\Database\Platform\ValueObjects\TableOptions;
/**
* Schema builder for database-independent table creation
*/
final readonly class SchemaBuilder
{
public function __construct(
private DatabasePlatform $platform,
private ConnectionInterface $connection
) {
}
public function createTable(string $tableName, array $columns, ?TableOptions $options = null): void
{
$tableOptions = $options ?? TableOptions::default();
$sql = $this->platform->getCreateTableSQL($tableName, $columns, $tableOptions);
$this->connection->execute($sql);
}
public function dropTable(string $tableName, bool $ifExists = true): void
{
$sql = $this->platform->getDropTableSQL($tableName, $ifExists);
$this->connection->execute($sql);
}
public function tableExists(string $tableName): bool
{
$sql = $this->platform->getTableExistsSQL($tableName);
$result = $this->connection->queryColumn($sql, [$tableName]);
return ! empty($result) && (int) $result[0] > 0;
}
public function createIndex(string $tableName, IndexDefinition $index): void
{
$sql = $this->platform->getCreateIndexSQL(
$tableName,
$index->name,
$index->columns,
['type' => $index->type]
);
$this->connection->execute($sql);
}
public function listTables(): array
{
$sql = $this->platform->getListTablesSQL();
return $this->connection->queryColumn($sql);
}
/**
* Builder pattern for table creation
*/
public function table(string $tableName): TableBuilder
{
return new TableBuilder($this, $tableName);
}
}
/**
* Fluent table builder for easy table creation
*/
final class TableBuilder
{
private array $columns = [];
private array $indexes = [];
private ?TableOptions $options = null;
public function __construct(
private readonly SchemaBuilder $schemaBuilder,
private readonly string $tableName
) {
}
public function id(string $name = 'id'): self
{
$this->columns[] = ColumnDefinition::id($name);
return $this;
}
public function string(string $name, int $length = 255, bool $nullable = true): self
{
$this->columns[] = ColumnDefinition::string($name, $length, $nullable);
return $this;
}
public function text(string $name, bool $nullable = true): self
{
$this->columns[] = ColumnDefinition::text($name, $nullable);
return $this;
}
public function integer(string $name, bool $nullable = true, bool $unsigned = false): self
{
$this->columns[] = ColumnDefinition::integer($name, $nullable, $unsigned);
return $this;
}
public function bigInteger(string $name, bool $nullable = true, bool $unsigned = false): self
{
$this->columns[] = ColumnDefinition::bigInteger($name, $nullable, $unsigned);
return $this;
}
public function decimal(string $name, int $precision = 8, int $scale = 2, bool $nullable = true): self
{
$this->columns[] = ColumnDefinition::decimal($name, $precision, $scale, $nullable);
return $this;
}
public function boolean(string $name, bool $nullable = true, ?bool $default = null): self
{
$this->columns[] = ColumnDefinition::boolean($name, $nullable, $default);
return $this;
}
public function timestamp(string $name, bool $nullable = true): self
{
$this->columns[] = ColumnDefinition::timestamp($name, $nullable);
return $this;
}
public function timestamps(): self
{
$timestamps = ColumnDefinition::timestamps();
foreach ($timestamps as $timestamp) {
$this->columns[] = $timestamp;
}
return $this;
}
public function json(string $name, bool $nullable = true): self
{
$this->columns[] = ColumnDefinition::json($name, $nullable);
return $this;
}
public function uuid(string $name, bool $nullable = true): self
{
$this->columns[] = ColumnDefinition::uuid($name, $nullable);
return $this;
}
public function column(ColumnDefinition $column): self
{
$this->columns[] = $column;
return $this;
}
public function index(IndexDefinition $index): self
{
$this->indexes[] = $index;
return $this;
}
public function unique(string $name, array $columns): self
{
$this->indexes[] = IndexDefinition::unique($name, $columns);
return $this;
}
public function foreignKey(string $column, string $referencedTable, string $referencedColumn = 'id'): self
{
// Foreign keys will be handled in a separate method/builder
return $this;
}
public function engine(string $engine): self
{
$this->options = ($this->options ?? TableOptions::default())->withCustomOption('engine', $engine);
return $this;
}
public function charset(string $charset, ?string $collation = null): self
{
$this->options = ($this->options ?? TableOptions::default())->withCharset($charset, $collation);
return $this;
}
public function comment(string $comment): self
{
$this->options = ($this->options ?? TableOptions::default())->withComment($comment);
return $this;
}
public function temporary(): self
{
$this->options = TableOptions::temporary();
return $this;
}
public function create(): void
{
if (empty($this->columns)) {
throw new \RuntimeException("Cannot create table '{$this->tableName}' without columns");
}
$this->schemaBuilder->createTable($this->tableName, $this->columns, $this->options);
// Create additional indexes
foreach ($this->indexes as $index) {
$this->schemaBuilder->createIndex($this->tableName, $index);
}
}
}

View File

@@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Services;
use App\Framework\Database\BatchRelationLoader;
use App\Framework\Database\Cache\EntityCacheManager;
use App\Framework\Database\Criteria\Criteria;
use App\Framework\Database\Criteria\CriteriaQuery;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Events\EntityEventManager;
use App\Framework\Database\HydratorInterface;
use App\Framework\Database\IdentityMap;
use App\Framework\Database\LazyLoader;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class EntityFinder
{
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private IdentityMap $identityMap,
private LazyLoader $lazyLoader,
private HydratorInterface $hydrator,
private BatchRelationLoader $batchRelationLoader,
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 = SqlQuery::create("SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?", [$id]);
$result = $this->databaseManager->getConnection()->query($query);
$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 = SqlQuery::create("SELECT {$metadata->idColumn} FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?", [$id]);
$result = $this->databaseManager->getConnection()->query($query);
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 = SqlQuery::select($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 = SqlQuery::select($metadata->tableName, [$metadata->idColumn]);
$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;
}
/**
* @deprecated Use findByCriteria() with DetachedCriteria instead for better type safety
* 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}";
}
$sqlQuery = SqlQuery::create($query, $params);
$result = $this->databaseManager->getConnection()->query($sqlQuery);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
$entities[] = $this->find($entityClass, $idValue); // Nutzt lazy loading
}
return $entities;
}
/**
* @deprecated Use findOneByCriteria() with DetachedCriteria instead for better type safety
* 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}";
}
$sqlQuery = SqlQuery::create($query, $params);
$result = $this->databaseManager->getConnection()->query($sqlQuery);
$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;
}
/**
* Prüft ob eine Entity mit der angegebenen ID existiert
*/
public function exists(string $entityClass, mixed $id): bool
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = SqlQuery::create("SELECT 1 FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ? LIMIT 1", [$id]);
$result = $this->databaseManager->getConnection()->query($query);
return (bool) $result->fetch();
}
/**
* 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();
$query = SqlQuery::create($sql, $parameters);
$result = $this->databaseManager->getConnection()->query($query);
$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): ?object
{
$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;
}
/**
* Helper methods for lazy loading
*/
public function isLazyGhost(object $entity): bool
{
return $this->lazyLoader->isLazyGhost($entity);
}
public function initializeLazyObject(object $entity): void
{
$this->lazyLoader->initializeLazyObject($entity);
}
}

View File

@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Services;
use App\Framework\Database\Cache\EntityCacheManager;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Events\EntityEventManager;
use App\Framework\Database\IdentityMap;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\TypeCaster\TypeCasterRegistry;
use App\Framework\Database\ValueObjects\SqlQuery;
final readonly class EntityPersister
{
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private IdentityMap $identityMap,
private EntityEventManager $entityEventManager,
private TypeCasterRegistry $typeCasterRegistry,
private ?EntityCacheManager $cacheManager = null
) {
}
/**
* 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 = SqlQuery::create("SELECT 1 FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ? LIMIT 1", [$id]);
$result = $this->databaseManager->getConnection()->query($query);
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[] = '?';
$value = $property->getValue($entity);
// Convert value objects to their database representations using TypeCasters
$params[] = $this->convertValueToDatabase($value);
}
// INSERT Query bauen
$data = array_combine($columns, $params);
// Debug logging for all data being inserted
error_log("EntityPersister INSERT - Table: " . $metadata->tableName);
foreach ($data as $column => $value) {
$length = is_string($value) ? strlen($value) : 'N/A';
error_log(" Column: $column, Value: " . var_export($value, true) . " (length: $length)");
}
$query = SqlQuery::insert($metadata->tableName, $data);
// Query ausführen
$result = $this->databaseManager->getConnection()->execute($query);
// 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[] = $this->convertValueToDatabase($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
$updateData = [];
$setColumns = [];
foreach ($setClause as $i => $clause) {
// Extract column name from "column = ?" format
$column = explode(' = ', $clause)[0];
$setColumns[] = $column;
$updateData[$column] = $params[$i];
}
$query = SqlQuery::update($metadata->tableName, $updateData, [$metadata->idColumn => $this->convertValueToDatabase($id)]);
// Query ausführen
$result = $this->databaseManager->getConnection()->query($query);
// 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 = SqlQuery::delete($metadata->tableName, [$metadata->idColumn => $this->convertValueToDatabase($id)]);
$this->databaseManager->getConnection()->query($query);
// 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;
}
/**
* Convert value objects to their database representations using TypeCasters
*/
private function convertValueToDatabase(mixed $value): mixed
{
if ($value === null) {
return null;
}
// Try to find a specific caster for this value's type
$valueType = is_object($value) ? get_class($value) : gettype($value);
$caster = $this->typeCasterRegistry->getCasterForType($valueType);
if ($caster !== null) {
$result = $caster->toDatabase($value);
// Debug logging for ULID issues
if ($valueType === 'App\Framework\Ulid\Ulid') {
error_log("ULID converted: " . var_export($result, true) . " (length: " . strlen($result) . ")");
}
return $result;
}
// Fallback for common cases
if (is_object($value)) {
// Check if object has toString method
if (method_exists($value, '__toString')) {
return (string) $value;
}
// For enums, get the value
if ($value instanceof \BackedEnum) {
return $value->value;
}
}
// Return primitive values as-is
return $value;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Services;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\HydratorInterface;
use App\Framework\Database\IdentityMap;
use App\Framework\Database\QueryBuilder\QueryBuilderFactory;
use App\Framework\Database\QueryBuilder\SelectQueryBuilder;
final readonly class EntityQueryManager
{
public function __construct(
private DatabaseManager $databaseManager,
private QueryBuilderFactory $queryBuilderFactory,
private IdentityMap $identityMap,
private HydratorInterface $hydrator
) {
}
/**
* 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);
}
/**
* 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();
}
// Neue Transaktion starten
$connection->beginTransaction();
try {
$result = $callback();
$connection->commit();
return $result;
} catch (\Throwable $e) {
$connection->rollback();
throw $e;
}
}
}

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Services;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Events\EntityEventManager;
use App\Framework\Database\IdentityMap;
use App\Framework\Database\IdGenerator;
use App\Framework\Database\Metadata\MetadataRegistry;
final readonly class EntityUtilities
{
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private IdentityMap $identityMap,
private EntityEventManager $entityEventManager
) {
}
/**
* Detach entity from identity map
*/
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
}
}
}
}
}
/**
* Clear identity map
*/
public function clear(): void
{
$this->identityMap->clear();
}
/**
* Get identity map statistics
*/
public function getIdentityMapStats(): array
{
return $this->identityMap->getStats();
}
/**
* Get entity metadata
*/
public function getMetadata(string $entityClass): \App\Framework\Database\Metadata\EntityMetadata
{
return $this->metadataRegistry->getMetadata($entityClass);
}
/**
* Generiert eine einzigartige ID für Entities
*/
public function generateId(): string
{
return IdGenerator::generate();
}
/**
* Get the identity map (for QueryBuilder integration)
*/
public function getIdentityMap(): IdentityMap
{
return $this->identityMap;
}
/**
* 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();
}
/**
* 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;
}
}

View File

@@ -18,11 +18,23 @@ final class Transaction
try {
$result = $callback($connection);
$connection->commit();
// Nur committen wenn wir noch in einer Transaktion sind
if ($connection->inTransaction()) {
$connection->commit();
}
return $result;
} catch (\Throwable $e) {
$connection->rollback();
// Nur rollbacken wenn wir noch in einer Transaktion sind
if ($connection->inTransaction()) {
try {
$connection->rollback();
} catch (\Throwable $rollbackException) {
// Rollback-Fehler ignorieren und Original-Exception weiterwerfen
// da die ursprüngliche Exception wichtiger ist
}
}
throw $e;
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
use App\Framework\TypeCaster\TypeCasterInterface as UniversalTypeCasterInterface;
/**
* Adapter to use new universal TypeCasters with the old Database interface
* This allows gradual migration from old to new TypeCaster system
*/
final readonly class DatabaseTypeCasterAdapter implements TypeCasterInterface
{
public function __construct(
private UniversalTypeCasterInterface $universalCaster
) {
}
public function fromDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if (!is_string($value)) {
// If it's not a string, try to convert it
$value = (string) $value;
}
return $this->universalCaster->fromString($value);
}
public function toDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
return $this->universalCaster->toString($value);
}
public function supports(string $type): bool
{
return $this->universalCaster->supports($type);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
use App\Framework\Filesystem\FilePath;
use InvalidArgumentException;
final class FilePathCaster implements TypeCasterInterface
{
public function fromDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if (is_string($value)) {
return FilePath::create($value);
}
throw new InvalidArgumentException('FilePath value must be a string');
}
public function toDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if ($value instanceof FilePath) {
return $value->toString();
}
throw new InvalidArgumentException('Value must be a FilePath instance');
}
public function supports(string $type): bool
{
return $type === FilePath::class;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
use App\Framework\Core\ValueObjects\FileSize;
use InvalidArgumentException;
final class FileSizeCaster implements TypeCasterInterface
{
public function fromDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if (is_int($value)) {
return FileSize::fromBytes($value);
}
if (is_string($value) && is_numeric($value)) {
return FileSize::fromBytes((int) $value);
}
throw new InvalidArgumentException('FileSize value must be an integer (bytes)');
}
public function toDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if ($value instanceof FileSize) {
return $value->toBytes();
}
throw new InvalidArgumentException('Value must be a FileSize instance');
}
public function supports(string $type): bool
{
return $type === FileSize::class;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
use App\Framework\Core\ValueObjects\Hash;
use InvalidArgumentException;
final class HashCaster implements TypeCasterInterface
{
public function fromDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if (is_string($value)) {
return Hash::fromString($value);
}
throw new InvalidArgumentException('Hash value must be a string');
}
public function toDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if ($value instanceof Hash) {
return $value->toString();
}
throw new InvalidArgumentException('Value must be a Hash instance');
}
public function supports(string $type): bool
{
return $type === Hash::class;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
use App\Framework\Http\MimeType;
use InvalidArgumentException;
final class MimeTypeCaster implements TypeCasterInterface
{
public function fromDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if (is_string($value)) {
$mimeType = MimeType::fromString($value);
if ($mimeType === null) {
throw new InvalidArgumentException("Unknown MIME type: {$value}");
}
return $mimeType;
}
throw new InvalidArgumentException('MimeType value must be a string');
}
public function toDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if ($value instanceof MimeType) {
return $value->value;
}
throw new InvalidArgumentException('Value must be a MimeType instance');
}
public function supports(string $type): bool
{
return $type === MimeType::class;
}
}

View File

@@ -6,6 +6,8 @@ namespace App\Framework\Database\TypeCaster;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\DI\Container;
use App\Framework\TypeCaster\TypeCasterRegistry as UniversalTypeCasterRegistry;
final class TypeCasterRegistry
{
@@ -17,15 +19,17 @@ final class TypeCasterRegistry
private bool $discovered = false;
public function __construct()
{
public function __construct(
private readonly Container $container,
private readonly UniversalTypeCasterRegistry $universalRegistry
) {
$this->discoverCasters();
}
public function register(string $casterClass, TypeCasterInterface $caster): void
{
$this->casters[$casterClass] = $caster;
$this->buildTypeMapping();
// Typ-Mapping wird jetzt lazy in getCasterForType() gemacht
}
public function get(string $casterClass): TypeCasterInterface
@@ -33,7 +37,7 @@ final class TypeCasterRegistry
if (! isset($this->casters[$casterClass])) {
// Automatisches Laden wenn Caster noch nicht registriert
if (! empty($casterClass) && ClassName::create($casterClass)->exists() && is_subclass_of($casterClass, TypeCasterInterface::class)) {
$this->casters[$casterClass] = new $casterClass();
$this->casters[$casterClass] = $this->container->get($casterClass);
$this->buildTypeMapping();
} else {
throw new DatabaseException("Type caster {$casterClass} not found or invalid");
@@ -58,7 +62,29 @@ final class TypeCasterRegistry
$this->discoverCasters();
}
return $this->typeToCaster[$type] ?? null;
// Erst in der bereits bekannten Mapping schauen
if (isset($this->typeToCaster[$type])) {
return $this->typeToCaster[$type];
}
// Falls nicht gefunden, alle Caster direkt testen (lazy discovery)
foreach ($this->casters as $caster) {
if ($caster->supports($type)) {
// Für nächstes Mal cachen
$this->typeToCaster[$type] = $caster;
return $caster;
}
}
// Fallback: Try universal registry and wrap with adapter
$universalCaster = $this->universalRegistry->getCasterForType($type);
if ($universalCaster !== null) {
$adapter = new DatabaseTypeCasterAdapter($universalCaster);
$this->typeToCaster[$type] = $adapter;
return $adapter;
}
return null;
}
/**
@@ -76,68 +102,24 @@ final class TypeCasterRegistry
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = $casterNamespace . $className;
// Nur echte Caster-Klassen laden, nicht das Interface
// Nur echte Caster-Klassen laden, nicht das Interface oder Adapter
if ($className !== 'TypeCasterInterface' &&
$className !== 'TypeCasterRegistry' &&
$className !== 'DatabaseTypeCasterAdapter' &&
! empty($fullClassName) &&
ClassName::create($fullClassName)->exists() &&
is_subclass_of($fullClassName, TypeCasterInterface::class) &&
! new \ReflectionClass($fullClassName)->isAbstract()) {
try {
$this->casters[$fullClassName] = new $fullClassName();
$this->casters[$fullClassName] = $this->container->get($fullClassName);
} catch (\Exception $e) {
// Caster konnte nicht instanziiert werden, überspringen
}
}
}
$this->buildTypeMapping();
$this->discovered = true;
}
/**
* Baut die Typ-zu-Caster-Zuordnung auf
*/
private function buildTypeMapping(): void
{
$this->typeToCaster = [];
foreach ($this->casters as $caster) {
// Alle bekannten Typen testen
$typesToTest = $this->getAllKnownTypes();
foreach ($typesToTest as $type) {
if ($caster->supports($type)) {
$this->typeToCaster[$type] = $caster;
}
}
}
}
/**
* Gibt alle bekannten Typen zurück, die getestet werden sollen
*/
private function getAllKnownTypes(): array
{
$types = [
// Primitive Typen
'int', 'float', 'string', 'bool', 'array', 'object',
// Häufige Value Objects (dynamisch erweitert)
];
// Alle existierenden Value Object Klassen hinzufügen
$valueObjectsPath = __DIR__ . '/../../../Domain/ValueObjects';
if (is_dir($valueObjectsPath)) {
$files = glob($valueObjectsPath . '/*.php');
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = 'App\\Domain\\ValueObjects\\' . $className;
if (! empty($fullClassName) && ClassName::create($fullClassName)->exists()) {
$types[] = $fullClassName;
}
}
}
return $types;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
use App\Framework\DateTime\SystemClock;
use App\Framework\Ulid\Ulid;
use InvalidArgumentException;
final class UlidCaster implements TypeCasterInterface
{
public function __construct(
private readonly SystemClock $clock
) {
}
public function fromDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if (is_string($value)) {
return Ulid::fromString($this->clock, $value);
}
throw new InvalidArgumentException('ULID value must be a string');
}
public function toDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if ($value instanceof Ulid) {
return $value->__toString();
}
throw new InvalidArgumentException('Value must be a Ulid instance');
}
public function supports(string $type): bool
{
return $type === Ulid::class;
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\TypeCaster\TypeCasterRegistry;
final readonly class TypeResolver
{
public function __construct(
private TypeCasterRegistry $casterRegistry
) {
}
/**
* Convert any value to a string suitable for use as array key
* Handles Value Objects (ULID, Email, etc.) by using their TypeCasters
*/
public function toArrayKey(mixed $value): string
{
if ($value === null) {
return '';
}
// Primitives can be directly converted
if (is_string($value) || is_int($value)) {
return (string) $value;
}
// For objects, try to find appropriate TypeCaster
if (is_object($value)) {
$objectType = get_class($value);
$caster = $this->casterRegistry->getCasterForType($objectType);
if ($caster) {
// Use the caster to convert to database format (usually string)
$converted = $caster->toDatabase($value);
return (string) $converted;
}
// Fallback: check for common string methods
if (method_exists($value, 'toString')) {
return $value->toString();
}
if (method_exists($value, '__toString')) {
return $value->__toString();
}
}
// Last resort: PHP string cast
return (string) $value;
}
/**
* Convert any value to database format using TypeCasters
* This is more general than toArrayKey and handles the full conversion process
*/
public function toDatabaseValue(mixed $value): mixed
{
if ($value === null) {
return null;
}
if (is_object($value)) {
$objectType = get_class($value);
$caster = $this->casterRegistry->getCasterForType($objectType);
if ($caster) {
return $caster->toDatabase($value);
}
}
// Return as-is for primitives and unknown objects
return $value;
}
/**
* Convert database value to object using TypeCasters
*/
public function fromDatabaseValue(mixed $value, string $targetType): mixed
{
if ($value === null) {
return null;
}
$caster = $this->casterRegistry->getCasterForType($targetType);
if ($caster) {
return $caster->fromDatabase($value);
}
// Return as-is if no caster found
return $value;
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\ValueObjects;
use InvalidArgumentException;
/**
* Value Object for SQL query parameters
* Provides type-safe parameter binding for prepared statements
*/
final readonly class QueryParameters
{
/**
* @param array<string, mixed> $parameters
*/
private readonly array $parameters;
public function __construct(array $parameters = [])
{
$this->validateParameters($parameters);
// Normalize parameter names for consistent access
$normalized = [];
foreach ($parameters as $name => $value) {
// Keep numeric keys as-is for positional parameters
if (is_int($name)) {
$normalized[$name] = $value;
} else {
$normalizedName = $this->normalizeParameterName($name);
$normalized[$normalizedName] = $value;
}
}
$this->parameters = $normalized;
}
public static function empty(): self
{
return new self([]);
}
/**
* @param array<string, mixed> $parameters
*/
public static function fromArray(array $parameters): self
{
return new self($parameters);
}
public function with(string $name, mixed $value): self
{
$name = $this->normalizeParameterName($name);
$parameters = $this->parameters;
$parameters[$name] = $value;
return new self($parameters);
}
/**
* @param array<string, mixed> $newParameters
*/
public function merge(array $newParameters): self
{
$merged = $this->parameters;
foreach ($newParameters as $name => $value) {
if (is_int($name)) {
$merged[$name] = $value;
} else {
$merged[$this->normalizeParameterName($name)] = $value;
}
}
return new self($merged);
}
public function without(string $name): self
{
$name = $this->normalizeParameterName($name);
$parameters = $this->parameters;
unset($parameters[$name]);
return new self($parameters);
}
public function has(string $name): bool
{
$name = $this->normalizeParameterName($name);
return array_key_exists($name, $this->parameters);
}
public function get(string $name, mixed $default = null): mixed
{
$name = $this->normalizeParameterName($name);
return $this->parameters[$name] ?? $default;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->parameters;
}
public function isEmpty(): bool
{
return empty($this->parameters);
}
public function count(): int
{
return count($this->parameters);
}
/**
* Get parameters for PDO binding (with : prefix)
* @return array<string, mixed>
*/
public function toPdoArray(): array
{
$pdoParams = [];
foreach ($this->parameters as $name => $value) {
if (is_int($name)) {
// Positional parameters use numeric keys as-is
$pdoParams[$name] = $value;
} else {
// Named parameters get : prefix if needed
$key = str_starts_with($name, ':') ? $name : ":{$name}";
$pdoParams[$key] = $value;
}
}
return $pdoParams;
}
/**
* Get parameter names that are used in the SQL
* @param string $sql
* @return array<string>
*/
public function getUsedParameters(string $sql): array
{
$used = [];
foreach (array_keys($this->parameters) as $name) {
$pattern = "/:{$name}\b/";
if (preg_match($pattern, $sql)) {
$used[] = $name;
}
}
return $used;
}
/**
* Get parameter names that are defined but not used in SQL
* @param string $sql
* @return array<string>
*/
public function getUnusedParameters(string $sql): array
{
$used = $this->getUsedParameters($sql);
return array_values(array_diff(array_keys($this->parameters), $used));
}
/**
* Validate that all parameters referenced in SQL are defined
*/
public function validateForSql(string $sql): void
{
// Find all parameter placeholders in SQL
preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $sql, $matches);
$requiredParams = array_unique($matches[1]);
$missing = [];
foreach ($requiredParams as $param) {
if (! $this->has($param)) {
$missing[] = $param;
}
}
if (! empty($missing)) {
throw new InvalidArgumentException(
'Missing required parameters: ' . implode(', ', $missing)
);
}
}
/**
* Get PDO parameter type for a value
*/
public function getPdoType(string $name): int
{
$value = $this->get($name);
return match (true) {
$value === null => \PDO::PARAM_NULL,
is_bool($value) => \PDO::PARAM_BOOL,
is_int($value) => \PDO::PARAM_INT,
default => \PDO::PARAM_STR
};
}
/**
* Normalize parameter name (remove : prefix if present)
*/
private function normalizeParameterName(string $name): string
{
return ltrim($name, ':');
}
/**
* @param array<string, mixed> $parameters
*/
private function validateParameters(array $parameters): void
{
foreach ($parameters as $name => $value) {
// Allow numeric keys for positional parameters (e.g., with ? placeholders)
if (! is_string($name) && ! is_int($name)) {
throw new InvalidArgumentException('Parameter names must be strings or integers');
}
// Skip validation for numeric keys (positional parameters)
if (is_int($name)) {
continue;
}
$normalizedName = $this->normalizeParameterName($name);
if (empty($normalizedName)) {
throw new InvalidArgumentException('Parameter name cannot be empty');
}
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $normalizedName)) {
throw new InvalidArgumentException(
"Invalid parameter name '{$normalizedName}'. Must start with letter or underscore and contain only alphanumeric characters and underscores"
);
}
// Validate value types (allow only scalar values and null)
if (! is_scalar($value) && $value !== null) {
throw new InvalidArgumentException(
"Parameter '{$normalizedName}' must be scalar or null, " . gettype($value) . ' given'
);
}
}
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\ValueObjects;
use InvalidArgumentException;
/**
* Value Object for SQL queries
* Encapsulates SQL statement with parameters for type safety and SQL injection prevention
*/
final readonly class SqlQuery
{
public function __construct(
public string $sql,
public QueryParameters $parameters = new QueryParameters([])
) {
if (empty(trim($sql))) {
throw new InvalidArgumentException('SQL query cannot be empty');
}
if (strlen($sql) > 1000000) { // 1MB limit
throw new InvalidArgumentException('SQL query is too large');
}
}
public static function create(string $sql, array $parameters = []): self
{
return new self($sql, QueryParameters::fromArray($parameters));
}
public static function select(string $table, array $columns = ['*'], ?string $where = null): self
{
$columnList = implode(', ', $columns);
$sql = "SELECT {$columnList} FROM {$table}";
if ($where) {
$sql .= " WHERE {$where}";
}
return new self($sql);
}
public static function insert(string $table, array $data): self
{
if (empty($data)) {
throw new InvalidArgumentException('Insert data cannot be empty');
}
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
return new self($sql, QueryParameters::fromArray($data));
}
public static function update(string $table, array $data, string $where): self
{
if (empty($data)) {
throw new InvalidArgumentException('Update data cannot be empty');
}
$sets = [];
foreach (array_keys($data) as $column) {
$sets[] = "{$column} = :{$column}";
}
$setClause = implode(', ', $sets);
$sql = "UPDATE {$table} SET {$setClause} WHERE {$where}";
return new self($sql, QueryParameters::fromArray($data));
}
public static function delete(string $table, string $where): self
{
$sql = "DELETE FROM {$table} WHERE {$where}";
return new self($sql);
}
public function withParameter(string $name, mixed $value): self
{
return new self($this->sql, $this->parameters->with($name, $value));
}
public function withParameters(array $parameters): self
{
return new self($this->sql, $this->parameters->merge($parameters));
}
public function isSelect(): bool
{
return preg_match('/^\s*SELECT\s+/i', $this->sql) === 1;
}
public function isInsert(): bool
{
return preg_match('/^\s*INSERT\s+/i', $this->sql) === 1;
}
public function isUpdate(): bool
{
return preg_match('/^\s*UPDATE\s+/i', $this->sql) === 1;
}
public function isDelete(): bool
{
return preg_match('/^\s*DELETE\s+/i', $this->sql) === 1;
}
public function isDDL(): bool
{
return preg_match('/^\s*(CREATE|ALTER|DROP)\s+/i', $this->sql) === 1;
}
public function isModifying(): bool
{
return $this->isInsert() || $this->isUpdate() || $this->isDelete() || $this->isDDL();
}
/**
* Get SQL with parameters for logging/debugging
* WARNING: Only use for debugging, never for execution
*/
public function toDebugString(): string
{
$sql = $this->sql;
foreach ($this->parameters->toArray() as $key => $value) {
$replacement = $value === null ? 'NULL' : var_export($value, true);
$sql = str_replace(":{$key}", $replacement, $sql);
}
return $sql;
}
public function toString(): string
{
return $this->sql;
}
public function __toString(): string
{
return $this->sql;
}
}