chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Framework\Database\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
class Column
{
public function __construct(
public ?string $name = null,
public ?string $type = null,
public bool $primary = false,
public bool $autoIncrement = false,
public bool $nullable = false
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Framework\Database\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Entity
{
public function __construct(
public ?string $tableName = null,
public string $idColumn = 'id'
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class Type
{
public function __construct(
public string $targetClass,
public ?string $foreignKey = null,
public ?string $localKey = null,
public string $type = 'hasMany'
) {}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Cache\Cache;
final class CacheAdapterStrategy implements CacheStrategy
{
private array $stats;
public function __construct(
private readonly Cache $cache,
private readonly string $keyPrefix = 'db_query:'
) {
$this->stats = [
'hits' => 0,
'misses' => 0,
'sets' => 0,
'deletes' => 0,
'invalidations' => 0,
];
}
public function set(QueryCacheKey $key, array $value, int $ttlSeconds): bool
{
$fullKey = $this->keyPrefix . $key->toString();
try {
$success = $this->cache->set($fullKey, $value, $ttlSeconds);
if ($success) {
$this->stats['sets']++;
}
return $success;
} catch (\Throwable) {
return false;
}
}
public function get(QueryCacheKey $key): ?array
{
$fullKey = $this->keyPrefix . $key->toString();
try {
$value = $this->cache->get($fullKey);
if ($value === null) {
$this->stats['misses']++;
return null;
}
$this->stats['hits']++;
return $value->value;
} catch (\Throwable) {
$this->stats['misses']++;
return null;
}
}
public function has(QueryCacheKey $key): bool
{
$fullKey = $this->keyPrefix . $key->toString();
try {
return $this->cache->has($fullKey);
} catch (\Throwable) {
return false;
}
}
public function delete(QueryCacheKey $key): bool
{
$fullKey = $this->keyPrefix . $key->toString();
try {
$success = $this->cache->forget($fullKey);
if ($success) {
$this->stats['deletes']++;
}
return $success;
} catch (\Throwable) {
return false;
}
}
public function invalidatePattern(string $pattern): int
{
// Fallback für Cache-Implementierungen ohne Pattern-Support
if (!method_exists($this->cache, 'deleteByPattern')) {
// Für einfache Caches: kompletter Clear bei Pattern-Invalidierung
$this->clear();
return 1; // Unbekannte Anzahl, also 1 als Indikator
}
try {
$searchPattern = $this->keyPrefix . '*' . $pattern . '*';
$deleted = $this->cache->deleteByPattern($searchPattern);
$this->stats['invalidations'] += $deleted;
return $deleted;
} catch (\Throwable) {
return 0;
}
}
public function clear(): void
{
try {
// Wenn Cache Tagged-Cache unterstützt, nutze Tags
if (method_exists($this->cache, 'deleteByTag')) {
$deleted = $this->cache->deleteByTag('database_query');
$this->stats['invalidations'] += $deleted;
return;
}
// Wenn Pattern-Deletion unterstützt wird
if (method_exists($this->cache, 'deleteByPattern')) {
$deleted = $this->cache->deleteByPattern($this->keyPrefix . '*');
$this->stats['invalidations'] += $deleted;
return;
}
// Fallback: Ganzer Cache wird geleert (nicht ideal)
if (method_exists($this->cache, 'clear')) {
$this->cache->clear();
$this->stats['invalidations']++;
}
} catch (\Throwable) {
// Ignoriere Fehler bei Cache-Clear
}
}
public function getStats(): array
{
$baseStats = $this->stats;
// Erweitere um Framework-Cache-spezifische Stats falls verfügbar
if (method_exists($this->cache, 'getStats')) {
try {
$cacheStats = $this->cache->getStats();
$baseStats = array_merge($baseStats, [
'cache_stats' => $cacheStats,
'cache_type' => get_class($this->cache),
]);
} catch (\Throwable) {
// Ignoriere Fehler bei Stats-Abruf
}
}
// Hit Ratio berechnen
$baseStats['hit_ratio'] = $this->calculateHitRatio();
return $baseStats;
}
private function calculateHitRatio(): float
{
$total = $this->stats['hits'] + $this->stats['misses'];
return $total > 0 ? ($this->stats['hits'] / $total) : 0.0;
}
/**
* Factory-Methode für TaggedCache wenn verfügbar
*/
public static function withTags(Cache $cache, array $tags = ['database_query'], string $keyPrefix = 'db_query:'): self
{
// Wenn TaggedCache verfügbar ist, nutze es
if (method_exists($cache, 'tags')) {
$taggedCache = $cache->tags($tags);
return new self($taggedCache, $keyPrefix);
}
return new self($cache, $keyPrefix);
}
/**
* Hilfsmethode für Cache-Tagging
*/
private function setWithTags(string $key, array $value, int $ttl, array $tags = []): bool
{
if (method_exists($this->cache, 'setWithTags')) {
return $this->cache->setWithTags($key, $value, $ttl, array_merge(['database_query'], $tags));
}
return $this->cache->set($key, $value, $ttl);
}
/**
* Invalidierung basierend auf Tabellen-Tags
*/
public function invalidateByTable(string $tableName): int
{
if (method_exists($this->cache, 'deleteByTag')) {
try {
return $this->cache->deleteByTag("table:{$tableName}");
} catch (\Throwable) {
// Fallback zu Pattern-basierter Invalidierung
}
}
// Fallback: Pattern-basierte Invalidierung
return $this->invalidatePattern("*{$tableName}*");
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
interface CacheStrategy
{
/**
* Speichert einen Wert im Cache
*/
public function set(QueryCacheKey $key, array $value, int $ttlSeconds): bool;
/**
* Lädt einen Wert aus dem Cache
*/
public function get(QueryCacheKey $key): ?array;
/**
* Prüft ob ein Schlüssel im Cache existiert
*/
public function has(QueryCacheKey $key): bool;
/**
* Löscht einen Schlüssel aus dem Cache
*/
public function delete(QueryCacheKey $key): bool;
/**
* Invalidiert Cache-Einträge basierend auf Pattern
*/
public function invalidatePattern(string $pattern): int;
/**
* Löscht alle Cache-Einträge
*/
public function clear(): void;
/**
* Gibt Cache-Statistiken zurück
*/
public function getStats(): array;
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Database\ConnectionInterface;
final readonly class QueryCacheKey
{
private string $hashedKey;
public function __construct(
private string $sql,
private array $parameters,
private ConnectionInterface $connection
) {
$this->hashedKey = $this->generateHash();
}
public function toString(): string
{
return $this->hashedKey;
}
public function __toString(): string
{
return $this->toString();
}
private function generateHash(): string
{
// Normalisiere SQL (Whitespace, Case)
$normalizedSql = $this->normalizeSql($this->sql);
// Sortiere Parameter für konsistente Hashes
$sortedParams = $this->normalizeParameters($this->parameters);
// Verwende Connection-spezifische Daten
$connectionInfo = $this->getConnectionInfo();
// Generiere deterministische Hash
$keyData = [
'sql' => $normalizedSql,
'params' => $sortedParams,
'connection' => $connectionInfo,
];
$serialized = serialize($keyData);
return 'query_cache:' . hash('sha256', $serialized);
}
private function normalizeSql(string $sql): string
{
// Entferne überflüssige Zeichen und normalisiere
$normalized = preg_replace('/\s+/', ' ', trim($sql));
return strtolower($normalized);
}
private function normalizeParameters(array $parameters): array
{
// Für assoziative Arrays: nach Schlüssel sortieren
if ($this->isAssociativeArray($parameters)) {
ksort($parameters);
}
// Normalisiere Werte
return array_map([$this, 'normalizeValue'], $parameters);
}
private function normalizeValue(mixed $value): mixed
{
// Strings trimmen
if (is_string($value)) {
return trim($value);
}
// Floats mit fester Präzision
if (is_float($value)) {
return round($value, 10);
}
// Objects zu String konvertieren wenn möglich
if (is_object($value) && method_exists($value, '__toString')) {
return (string) $value;
}
return $value;
}
private function isAssociativeArray(array $array): bool
{
return array_keys($array) !== range(0, count($array) - 1);
}
private function getConnectionInfo(): array
{
try {
$pdo = $this->connection->getPdo();
return [
'driver' => $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME),
'server_version' => $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION),
// Keine sensitive Daten wie Host/Database für bessere Portabilität
];
} catch (\Throwable) {
return ['driver' => 'unknown'];
}
}
public function getSql(): string
{
return $this->sql;
}
public function getParameters(): array
{
return $this->parameters;
}
public function matches(string $pattern): bool
{
// Ermöglicht Pattern-basierte Invalidierung
return fnmatch($pattern, $this->hashedKey) ||
str_contains($this->normalizeSql($this->sql), strtolower($pattern));
}
public function containsTable(string $tableName): bool
{
$normalizedSql = $this->normalizeSql($this->sql);
// Einfache Heuristik für Tabellenerkennung
$patterns = [
" from {$tableName} ",
" join {$tableName} ",
" into {$tableName} ",
" update {$tableName} ",
];
foreach ($patterns as $pattern) {
if (str_contains($normalizedSql, strtolower($pattern))) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
/**
* Einfache Cache-Strategy als Fallback wenn kein Framework-Cache verfügbar
*/
final class SimpleCacheStrategy implements CacheStrategy
{
private array $cache = [];
private array $expiry = [];
private array $stats = [
'hits' => 0,
'misses' => 0,
'sets' => 0,
'deletes' => 0,
'invalidations' => 0,
];
public function __construct(
private readonly string $keyPrefix = 'db_query:'
) {}
public function set(QueryCacheKey $key, array $value, int $ttlSeconds): bool
{
$keyString = $this->keyPrefix . $key->toString();
$this->cache[$keyString] = $value;
$this->expiry[$keyString] = time() + $ttlSeconds;
$this->stats['sets']++;
return true;
}
public function get(QueryCacheKey $key): ?array
{
$keyString = $this->keyPrefix . $key->toString();
// Prüfe Ablauf
if (isset($this->expiry[$keyString]) && time() > $this->expiry[$keyString]) {
$this->delete($key);
$this->stats['misses']++;
return null;
}
if (isset($this->cache[$keyString])) {
$this->stats['hits']++;
return $this->cache[$keyString];
}
$this->stats['misses']++;
return null;
}
public function has(QueryCacheKey $key): bool
{
return $this->get($key) !== null;
}
public function delete(QueryCacheKey $key): bool
{
$keyString = $this->keyPrefix . $key->toString();
$existed = isset($this->cache[$keyString]);
unset($this->cache[$keyString], $this->expiry[$keyString]);
if ($existed) {
$this->stats['deletes']++;
}
return $existed;
}
public function invalidatePattern(string $pattern): int
{
$deleted = 0;
foreach (array_keys($this->cache) as $keyString) {
if (fnmatch($pattern, $keyString)) {
unset($this->cache[$keyString], $this->expiry[$keyString]);
$deleted++;
}
}
$this->stats['invalidations'] += $deleted;
return $deleted;
}
public function clear(): void
{
$count = count($this->cache);
$this->cache = [];
$this->expiry = [];
$this->stats['invalidations'] += $count;
}
public function getStats(): array
{
return array_merge($this->stats, [
'size' => count($this->cache),
'memory_usage' => $this->estimateMemoryUsage(),
'hit_ratio' => $this->calculateHitRatio(),
'cache_type' => 'SimpleCacheStrategy'
]);
}
private function estimateMemoryUsage(): int
{
return memory_get_usage() - memory_get_usage(true); // Grobe Schätzung
}
private function calculateHitRatio(): float
{
$total = $this->stats['hits'] + $this->stats['misses'];
return $total > 0 ? ($this->stats['hits'] / $total) : 0.0;
}
/**
* Bereinigt abgelaufene Einträge
*/
public function cleanup(): int
{
$currentTime = time();
$cleaned = 0;
foreach ($this->expiry as $keyString => $expiry) {
if ($currentTime > $expiry) {
unset($this->cache[$keyString], $this->expiry[$keyString]);
$cleaned++;
}
}
return $cleaned;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
use App\Framework\Database\Driver\DriverConfig;
final class DatabaseConfig
{
public function __construct(
public DriverConfig $driverConfig,
public PoolConfig $poolConfig,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
final class PoolConfig
{
public function __construct(
public bool $enabled,
public int $maxConnections,
public int $minConnections,
) {}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
interface ConnectionInterface
{
public function execute(string $sql, array $parameters = []): int;
public function query(string $sql, array $parameters = []): ResultInterface;
public function queryOne(string $sql, array $parameters = []): ?array;
public function queryColumn(string $sql, array $parameters = []): array;
public function queryScalar(string $sql, array $parameters = []): mixed;
public function beginTransaction(): void;
public function commit(): void;
public function rollback(): void;
public function inTransaction(): bool;
public function lastInsertId(): string;
public function getPdo(): \PDO;
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Exception\DatabaseException;
final class ConnectionPool
{
private array $config;
private array $connections = [];
private array $inUse = [];
private int $maxConnections;
private int $minConnections;
private int $currentConnections = 0;
public function __construct(array $config, int $maxConnections = 10, int $minConnections = 2)
{
$this->config = $config;
$this->maxConnections = $maxConnections;
$this->minConnections = $minConnections;
$this->initializeMinConnections();
}
public function getConnection(): PooledConnection
{
foreach ($this->connections as $id => $connection) {
if (!isset($this->inUse[$id])) {
$this->inUse[$id] = true;
return new PooledConnection($connection, $this, $id);
}
}
if ($this->currentConnections < $this->maxConnections) {
$connection = DatabaseFactory::createConnection($this->config);
$id = uniqid('conn_');
$this->connections[$id] = $connection;
$this->inUse[$id] = true;
$this->currentConnections++;
return new PooledConnection($connection, $this, $id);
}
throw new DatabaseException('Maximum number of connections reached');
}
public function releaseConnection(string $id): void
{
unset($this->inUse[$id]);
}
public function closeConnection(string $id): void
{
unset($this->connections[$id], $this->inUse[$id]);
$this->currentConnections--;
}
public function getStats(): array
{
return [
'total_connections' => $this->currentConnections,
'active_connections' => count($this->inUse),
'free_connections' => $this->currentConnections - count($this->inUse),
'max_connections' => $this->maxConnections,
];
}
private function initializeMinConnections(): void
{
for ($i = 0; $i < $this->minConnections; $i++) {
$connection = DatabaseFactory::createConnection($this->config);
$id = uniqid('conn_');
$this->connections[$id] = $connection;
$this->currentConnections++;
}
}
public function __destruct()
{
$this->connections = [];
$this->inUse = [];
$this->currentConnections = 0;
}
}

View File

@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Cache\Cache;
use App\Framework\Database\Cache\CacheStrategy;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Driver\Driver;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Driver\DriverType;
use App\Framework\Database\Driver\MysqlDriver;
use App\Framework\Database\Driver\PostgresDriver;
use App\Framework\Database\Driver\SqliteDriver;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\Middleware\MiddlewarePipeline;
use App\Framework\Database\Middleware\CacheMiddleware;
use App\Framework\Database\Middleware\RetryMiddleware;
use App\Framework\Database\Middleware\HealthCheckMiddleware;
use App\Framework\Database\Cache\CacheAdapterStrategy;
use App\Framework\Database\Cache\SimpleCacheStrategy;
use Pdo\Mysql;
use Pdo\Pgsql;
use Pdo\Sqlite;
final readonly class DatabaseFactory
{
public static function createConnection(
array|DriverConfig $config,
array $middlewareConfig = []
): ConnectionInterface {
$lazy = $middlewareConfig['lazy'] ?? false;
$retry = $middlewareConfig['retry'] ?? false;
$healthCheck = $middlewareConfig['health_check'] ?? false;
$cache = $middlewareConfig['cache'] ?? false;
if (!$lazy && !$retry && !$healthCheck && !$cache) {
// Keine Middleware - direkte Verbindung
return self::createDirectConnection($config);
}
// Base Connection erstellen
if ($lazy) {
$baseConnection = LazyConnectionFactory::createLazyGhost($config);
} else {
$baseConnection = self::createDirectConnection($config);
}
// Middleware-Pipeline erstellen
$pipeline = new MiddlewarePipeline();
if ($cache) {
$cacheConfig = is_array($cache) ? $cache : [];
$cacheStrategy = self::createCacheStrategy($cacheConfig);
$pipeline->add(new CacheMiddleware(
$cacheStrategy,
$cacheConfig['ttl'] ?? 300,
$cacheConfig['enabled'] ?? true,
$cacheConfig['cacheable_operations'] ?? ['query', 'queryOne', 'queryColumn', 'queryScalar']
));
}
if ($retry) {
$retryConfig = is_array($retry) ? $retry : [];
$pipeline->add(new RetryMiddleware(
$retryConfig['max_retries'] ?? 3,
$retryConfig['delay_ms'] ?? 100,
$retryConfig['retryable_exceptions'] ?? [\PDOException::class, DatabaseException::class]
));
}
if ($healthCheck) {
$healthConfig = is_array($healthCheck) ? $healthCheck : [];
$pipeline->add(new HealthCheckMiddleware(
$healthConfig['interval'] ?? 30,
$healthConfig['enabled'] ?? true
));
}
// Wenn Pipeline leer ist, gib direkt die Base Connection zurück
if (empty($pipeline->getMiddleware())) {
return $baseConnection;
}
return new MiddlewareConnection($baseConnection, $pipeline);
}
public static function createDirectConnection(array|DriverConfig $config): ConnectionInterface
{
if(is_array($config)) {
$config = DriverConfig::fromArray($config);
}
$driver = self::createDriver($config);
$pdo = self::createPdo($driver);
return new PdoConnection($pdo);
}
public static function createLazyConnection(
array|DriverConfig $config,
array $additionalMiddleware = []
): ConnectionInterface {
return self::createConnection($config, array_merge(
['lazy' => true],
$additionalMiddleware
));
}
public static function createRetryableConnection(
array|DriverConfig $config,
int $maxRetries = 3,
int $retryDelayMs = 100
): ConnectionInterface {
return self::createConnection($config, [
'retry' => [
'max_retries' => $maxRetries,
'delay_ms' => $retryDelayMs
]
]);
}
public static function createRobustConnection(
array|DriverConfig $config,
array $middlewareConfig = []
): ConnectionInterface {
// Standard "robuste" Konfiguration
$defaultConfig = [
'lazy' => true,
'retry' => [
'max_retries' => 3,
'delay_ms' => 100
],
'health_check' => [
'interval' => 30,
'enabled' => true
]
];
return self::createConnection($config, array_merge($defaultConfig, $middlewareConfig));
}
public static function createPureLazyConnection(array|DriverConfig $config): ConnectionInterface
{
// Reine LazyGhost-Connection ohne zusätzliche Middleware
return LazyConnectionFactory::createLazyGhost($config);
}
public static function isLazyConnection(ConnectionInterface $connection): bool
{
if ($connection instanceof MiddlewareConnection) {
$baseConnection = $connection->getBaseConnection();
return LazyConnectionFactory::isLazyGhost($baseConnection);
}
return LazyConnectionFactory::isLazyGhost($connection);
}
public static function forceLazyInitialization(ConnectionInterface $connection): void
{
if ($connection instanceof MiddlewareConnection) {
$baseConnection = $connection->getBaseConnection();
if (LazyConnectionFactory::isLazyGhost($baseConnection)) {
LazyConnectionFactory::initializeLazyGhost($baseConnection);
}
} elseif (LazyConnectionFactory::isLazyGhost($connection)) {
LazyConnectionFactory::initializeLazyGhost($connection);
}
}
private static function createCacheStrategy(array $config): CacheStrategy
{
// Verwende direktes CacheInterface oder fallback zu ArrayCache
$keyPrefix = $config['prefix'] ?? 'db_query:';
$tags = $config['tags'] ?? ['database_query'];
// Wenn eine CacheInterface-Instanz direkt übergeben wird
if (isset($config['cache_instance']) && $config['cache_instance'] instanceof Cache) {
return new CacheAdapterStrategy($config['cache_instance'], $keyPrefix);
}
// Fallback zu einfacher Array-basierter Implementierung
return new SimpleCacheStrategy($keyPrefix);
}
/**
* Erstelle Cache-Strategy mit direkter CacheInterface-Instanz
*/
public static function createCacheStrategyFromCache(
Cache $cache,
string $keyPrefix = 'db_query:',
array $tags = ['database_query']
): CacheStrategy {
return CacheAdapterStrategy::withTags($cache, $tags, $keyPrefix);
}
public static function createCachedConnection(
array|DriverConfig $config,
array $cacheConfig = []
): ConnectionInterface {
return self::createConnection($config, [
'cache' => array_merge([
'ttl' => 300,
'enabled' => true,
'prefix' => 'db_query:',
'tags' => ['database_query']
], $cacheConfig)
]);
}
public static function createCachedConnectionWithCache(
array|DriverConfig $config,
Cache $cache,
array $additionalConfig = []
): ConnectionInterface {
return self::createCachedConnection($config, array_merge([
'cache_instance' => $cache
], $additionalConfig));
}
/**
* Erstelle Connection mit externem Cache-System
*/
public static function createConnectionWithCustomCache(
array|DriverConfig $config,
Cache $cache,
string $keyPrefix = 'db_query:',
int $ttl = 300
): ConnectionInterface {
return self::createConnection($config, [
'cache' => [
'cache_instance' => $cache,
'prefix' => $keyPrefix,
'ttl' => $ttl,
'enabled' => true
]
]);
}
public static function createFullFeaturedConnection(
array|DriverConfig $config,
array $middlewareConfig = []
): ConnectionInterface {
// "Alles dabei" - Connection mit allen Features
$defaultConfig = [
'lazy' => true,
'cache' => [
'ttl' => 300,
'enabled' => true,
'prefix' => 'db_query:',
'tags' => ['database_query']
],
'retry' => [
'max_retries' => 3,
'delay_ms' => 100
],
'health_check' => [
'interval' => 30,
'enabled' => true
]
];
return self::createConnection($config, array_merge($defaultConfig, $middlewareConfig));
}
public static function createProductionConnection(
array|DriverConfig $config,
?Cache $cache = null,
array $middlewareConfig = []
): ConnectionInterface {
// Production-optimierte Konfiguration
$defaultConfig = [
'lazy' => true,
'cache' => [
'ttl' => 1800,
'enabled' => true,
'prefix' => 'prod_db:',
'tags' => ['database_query', 'production']
],
'retry' => [
'max_retries' => 5,
'delay_ms' => 200
],
'health_check' => [
'interval' => 60,
'enabled' => true
]
];
// Wenn externe Cache-Instanz übergeben wird
if ($cache !== null) {
$defaultConfig['cache']['cache_instance'] = $cache;
}
return self::createConnection($config, array_merge($defaultConfig, $middlewareConfig));
}
private static function createDriver(DriverConfig $config): Driver
{
return match($config->driverType) {
DriverType::MYSQL => new MysqlDriver($config),
DriverType::PGSQL => new PostgresDriver($config),
DriverType::SQLITE => new SqliteDriver($config),
};
}
private static function createPdo(Driver $driver): \PDO
{
return match($driver->config->driverType) {
DriverType::MYSQL => new Mysql(
$driver->dsn,
$driver->config->username,
$driver->config->password,
$driver->options
),
DriverType::PGSQL => new Pgsql(
$driver->dsn,
$driver->config->username,
$driver->config->password,
$driver->options
),
DriverType::SQLITE => new Sqlite(
$driver->dsn,
$driver->config->username,
$driver->config->password,
$driver->options
),
};
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Migration\MigrationLoader;
use App\Framework\Database\Migration\MigrationRunner;
final class DatabaseManager
{
private array $config;
private ?ConnectionPool $connectionPool = null;
private ?ReadWriteConnection $readWriteConnection = null;
public function __construct(
array $config = [],
private readonly string $migrationsPath = 'database/migrations'
){
$this->config = $config;
}
public function getConnection(): ConnectionInterface
{
if (isset($this->config['pool']) && $this->config['pool']['enabled']) {
return $this->getPooledConnection();
}
if (isset($this->config['read_write']) && $this->config['read_write']['enabled']) {
return $this->getReadWriteConnection();
}
return DatabaseFactory::createConnection($this->config);
}
public function getPooledConnection(): PooledConnection
{
if ($this->connectionPool === null) {
$poolConfig = $this->config['pool'] ?? [];
$this->connectionPool = new ConnectionPool(
$this->config,
$poolConfig['max_connections'] ?? 10,
$poolConfig['min_connections'] ?? 2
);
}
return $this->connectionPool->getConnection();
}
public function getReadWriteConnection(): ReadWriteConnection
{
if ($this->readWriteConnection === null) {
$writeConnection = DatabaseFactory::createConnection($this->config);
$readConnections = [];
foreach ($this->config['read_write']['read_connections'] as $readConfig) {
$readConnections[] = DatabaseFactory::createConnection(
array_merge($this->config, $readConfig)
);
}
$this->readWriteConnection = new ReadWriteConnection($writeConnection, $readConnections);
}
return $this->readWriteConnection;
}
public function migrate(?string $migrationsPath = null): array
{
$migrationsPath = $migrationsPath ?? $this->migrationsPath;
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
return $runner->migrate($migrations);
}
public function rollback(?string $migrationsPath = null, int $steps = 1): array
{
$migrationsPath = $migrationsPath ?? $this->migrationsPath;
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
return $runner->rollback($migrations, $steps);
}
public function getMigrationStatus(?string $migrationsPath = null): array
{
$migrationsPath = $migrationsPath ?? $this->migrationsPath;
$loader = new MigrationLoader($migrationsPath);
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
return $runner->getStatus($migrations);
}
public function getConnectionPoolStats(): array
{
return $this->connectionPool?->getStats() ?? [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\Database\Driver;
interface Driver
{
public string $dsn {get;}
public array $options {get;}
public DriverConfig $config {get;}
public function __construct(
DriverConfig $config
);
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Framework\Database\Driver;
final readonly class DriverConfig
{
public function __construct(
public DriverType $driverType,
public string $host,
public int $port,
public string $database,
public string $username,
public string $password,
public string $charset
){}
public static function fromArray(array $config): DriverConfig
{
return new self(
driverType: DriverType::tryFrom($config['driver']) ?? DriverType::MYSQL,
host: $config['host'],
port: $config['port'],
database: $config['database'],
username: $config['username'],
password: $config['password'],
charset: $config['charset'] ?? 'utf8mb4'
);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Framework\Database\Driver;
enum DriverType:string
{
case MYSQL = 'mysql';
case PGSQL = 'pgsql';
case SQLITE = 'sqlite';
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Framework\Database\Driver;
final readonly class MysqlDriver implements Driver
{
public string $dsn;
public array $options;
public function __construct(
public DriverConfig $config,
){
$this->dsn = $this->createDns();
$this->options = $this->getOptions();
}
private function createDns(): string
{
return sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config->host,
$this->config->port,
$this->config->database,
$this->config->charset,
);
}
private function getOptions(): array
{
return [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Framework\Database\Driver;
final readonly class PostgresDriver implements Driver
{
public string $dsn;
public array $options;
public function __construct(
public DriverConfig $config,
){
$this->dsn = $this->createDns();
$this->options = $this->getOptions();
}
private function createDns(): string
{
return sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$this->config->host,
$this->config->port,
$this->config->database,
);
}
private function getOptions(): array
{
return [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
// PostgreSQL-spezifische Optionen
\PDO::ATTR_PERSISTENT => false,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Framework\Database\Driver;
final readonly class SqliteDriver implements Driver
{
public string $dsn;
public array $options;
public function __construct(
public DriverConfig $config,
){
$this->dsn = $this->createDns();
$this->options = $this->getOptions();
}
private function createDns(): string
{
// Für SQLite verwenden wir den database-Parameter als Dateipfad
// Falls es ein relativer Pfad ist, wird er relativ zum aktuellen Verzeichnis interpretiert
// Für In-Memory-Datenbank kann ":memory:" verwendet werden
return sprintf(
'sqlite:%s',
$this->config->database
);
}
private function getOptions(): array
{
return [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
// SQLite-spezifische Optionen
\PDO::ATTR_TIMEOUT => 30,
];
}
}

View File

@@ -0,0 +1,469 @@
<?php
namespace App\Framework\Database;
use App\Framework\Attributes\Singleton;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\MetadataRegistry;
#[Singleton]
final readonly class EntityManager
{
private Hydrator $hydrator;
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private TypeConverter $typeConverter,
private IdentityMap $identityMap,
private LazyLoader $lazyLoader
) {
$this->hydrator = new Hydrator($this->typeConverter, $this);
}
/**
* Findet Entity - standardmäßig lazy loading
*/
public function find(string $entityClass, mixed $id): ?object
{
// Prüfe Identity Map zuerst
if ($this->identityMap->has($entityClass, $id)) {
return $this->identityMap->get($entityClass, $id);
}
return $this->findWithLazyLoading($entityClass, $id);
}
/**
* Findet Entity und lädt sie sofort (eager loading)
*/
public function findEager(string $entityClass, mixed $id): ?object
{
// Prüfe Identity Map zuerst
if ($this->identityMap->has($entityClass, $id)) {
$entity = $this->identityMap->get($entityClass, $id);
// Falls es ein Ghost ist, initialisiere es
if ($this->isLazyGhost($entity)) {
$this->initializeLazyObject($entity);
}
return $entity;
}
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
$data = $result->fetch();
if (!$data) {
return null;
}
$entity = $this->hydrator->hydrate($metadata, $data);
// In Identity Map speichern
$this->identityMap->set($entityClass, $id, $entity);
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
return $this->lazyLoader->createLazyGhost($metadata, $id);
}
/**
* Referenz auf Entity (ohne Existenz-Check)
*/
public function getReference(string $entityClass, mixed $id): object
{
// Prüfe Identity Map
if ($this->identityMap->has($entityClass, $id)) {
return $this->identityMap->get($entityClass, $id);
}
$metadata = $this->metadataRegistry->getMetadata($entityClass);
return $this->lazyLoader->createLazyGhost($metadata, $id);
}
/**
* Findet alle Entities - standardmäßig lazy
*/
public function findAll(string $entityClass): array
{
return $this->findAllLazy($entityClass);
}
/**
* Findet alle Entities und lädt sie sofort
*/
public function findAllEager(string $entityClass): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT * FROM {$metadata->tableName}";
$result = $this->databaseManager->getConnection()->query($query);
$entities = [];
foreach ($result->fetchAll() as $data) {
// Prüfe Identity Map für jede Entity
$idValue = $data[$metadata->idColumn];
if ($this->identityMap->has($entityClass, $idValue)) {
$entity = $this->identityMap->get($entityClass, $idValue);
// Falls Ghost, initialisiere es für eager loading
if ($this->isLazyGhost($entity)) {
$this->initializeLazyObject($entity);
}
$entities[] = $entity;
} else {
$entity = $this->hydrator->hydrate($metadata, $data);
$this->identityMap->set($entityClass, $idValue, $entity);
$entities[] = $entity;
}
}
return $entities;
}
/**
* Interne Methode für lazy findAll
*/
private function findAllLazy(string $entityClass): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
// Nur IDs laden
$query = "SELECT {$metadata->idColumn} FROM {$metadata->tableName}";
$result = $this->databaseManager->getConnection()->query($query);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
if ($this->identityMap->has($entityClass, $idValue)) {
$entities[] = $this->identityMap->get($entityClass, $idValue);
} else {
$entities[] = $this->lazyLoader->createLazyGhost($metadata, $idValue);
}
}
return $entities;
}
/**
* Findet Entities nach Kriterien
*/
public function findBy(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT {$metadata->idColumn} FROM {$metadata->tableName}";
$params = [];
if (!empty($criteria)) {
$conditions = [];
foreach ($criteria as $field => $value) {
$columnName = $metadata->getColumnName($field);
$conditions[] = "{$columnName} = ?";
$params[] = $value;
}
$query .= " WHERE " . implode(' AND ', $conditions);
}
if ($orderBy) {
$orderClauses = [];
foreach ($orderBy as $field => $direction) {
$columnName = $metadata->getColumnName($field);
$orderClauses[] = "{$columnName} " . strtoupper($direction);
}
$query .= " ORDER BY " . implode(', ', $orderClauses);
}
if ($limit) {
$query .= " LIMIT {$limit}";
}
$result = $this->databaseManager->getConnection()->query($query, $params);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
$entities[] = $this->find($entityClass, $idValue); // Nutzt lazy loading
}
return $entities;
}
/**
* Findet eine Entity nach Kriterien
*/
public function findOneBy(string $entityClass, array $criteria): ?object
{
$results = $this->findBy($entityClass, $criteria, limit: 1);
return $results[0] ?? null;
}
/**
* Utility Methods
*/
public function detach(object $entity): void
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// ID der Entity ermitteln
$constructor = $metadata->reflection->getConstructor();
if ($constructor) {
foreach ($constructor->getParameters() as $param) {
$paramName = $param->getName();
$propertyMetadata = $metadata->getProperty($paramName);
if ($propertyMetadata && $propertyMetadata->columnName === $metadata->idColumn) {
try {
$property = $metadata->reflection->getProperty($paramName);
$id = $property->getValue($entity);
$this->identityMap->remove($entity::class, $id);
break;
} catch (\ReflectionException) {
// Property nicht gefunden
}
}
}
}
}
public function clear(): void
{
$this->identityMap->clear();
}
public function getIdentityMapStats(): array
{
return $this->identityMap->getStats();
}
public function isLazyGhost(object $entity): bool
{
return $this->lazyLoader->isLazyGhost($entity);
}
public function initializeLazyObject(object $entity): void
{
$this->lazyLoader->initializeLazyObject($entity);
}
public function getMetadata(string $entityClass): EntityMetadata
{
return $this->metadataRegistry->getMetadata($entityClass);
}
/**
* Generiert eine einzigartige ID für Entities
*/
public function generateId(): string
{
return IdGenerator::generate();
}
/**
* 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)) {
return $this->update($entity);
} else {
return $this->insert($entity);
}
}
/**
* Prüft ob eine Entity mit der angegebenen ID existiert
*/
public function exists(string $entityClass, mixed $id): bool
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT 1 FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ? LIMIT 1";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
return (bool) $result->fetch();
}
/**
* Fügt eine neue Entity ein (INSERT)
*/
public function insert(object $entity): object
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// Columnnamen und Values sammeln
$columns = [];
$values = [];
$params = [];
foreach ($metadata->properties as $propertyName => $propertyMetadata) {
// ID Property überspringen wenn auto-increment
if ($propertyName === $metadata->idProperty && $propertyMetadata->autoIncrement) {
continue;
}
// Property-Wert auslesen
$property = $metadata->reflection->getProperty($propertyName);
if(!$property->isInitialized($entity)) {
continue;
}
$columns[] = $propertyMetadata->columnName;
$values[] = '?';
$params[] = $property->getValue($entity);
}
// INSERT Query bauen
$query = "INSERT INTO {$metadata->tableName} (" . implode(', ', $columns) . ") "
. "VALUES (" . implode(', ', $values) . ")";
// Query ausführen
$result = $this->databaseManager->getConnection()->execute($query, $params);
// In Identity Map speichern
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
$this->identityMap->set($entity::class, $id, $entity);
return $entity;
}
/**
* Aktualisiert eine vorhandene Entity (UPDATE)
*/
public function update(object $entity): object
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// 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;
}
$setClause[] = "{$propertyMetadata->columnName} = ?";
// Property-Wert auslesen
$property = $metadata->reflection->getProperty($propertyName);
$params[] = $property->getValue($entity);
}
// ID für WHERE Clause hinzufügen
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
$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);
return $entity;
}
/**
* Löscht eine Entity
*/
public function delete(object $entity): void
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// ID auslesen
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
// DELETE Query ausführen
$query = "DELETE FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$this->databaseManager->getConnection()->query($query, [$id]);
// Aus Identity Map entfernen
$this->identityMap->remove($entity::class, $id);
}
/**
* Speichert mehrere Entities auf einmal
*/
public function saveAll(object ...$entities): array
{
$result = [];
foreach ($entities as $entity) {
$result[] = $this->save($entity);
}
return $result;
}
/**
* Führt eine Funktion in einer Transaktion aus
*/
public function transaction(callable $callback): mixed
{
$connection = $this->databaseManager->getConnection();
// Wenn bereits in einer Transaktion, führe Callback direkt aus
if ($connection->inTransaction()) {
return $callback($this);
}
// Neue Transaktion starten
$connection->beginTransaction();
try {
$result = $callback($this);
$connection->commit();
return $result;
} catch (\Throwable $e) {
$connection->rollback();
throw $e;
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Metadata\MetadataExtractor;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\TypeCaster\TypeCasterRegistry;
final class EntityManagerFactory
{
public static function create(DatabaseManager $databaseManager): EntityManager
{
$metadataExtractor = new MetadataExtractor();
$metadataRegistry = new MetadataRegistry($metadataExtractor);
$casterRegistry = new TypeCasterRegistry();
$typeConverter = new TypeConverter($casterRegistry);
#$hydrator = new Hydrator($typeConverter);
$identityMap = new IdentityMap();
$lazyLoader = new LazyLoader(
databaseManager: $databaseManager,
typeConverter: $typeConverter,
identityMap: $identityMap,
metadataRegistry: $metadataRegistry
);
return new EntityManager(
databaseManager: $databaseManager,
metadataRegistry: $metadataRegistry,
typeConverter: $typeConverter,
identityMap: $identityMap,
lazyLoader: $lazyLoader
);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Framework\Database;
use App\Domain\User\User;
use App\Framework\Attributes\Singleton;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\Config\PoolConfig;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Driver\DriverType;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
final readonly class EntityManagerInitializer
{
#[Initializer]
public function __invoke(Container $container): EntityManager
{
$config = [
'driver' => 'mysql',
'host' => 'db',
'port' => 3306,
'username' => 'mdb-user',
'password' => 'dfghreh5465fghfgh',
'database' => 'database',
];
$newConfig = new DatabaseConfig(
new DriverConfig(
driverType: DriverType::MYSQL,
host : 'mysql',
port : 3306,
database : 'database',
username : "mdb-user",
password : 'dfghreh5465fghfgh',
charset : 'utf8mb4',
),
new PoolConfig(
enabled: true,
maxConnections: 5,
minConnections: 2,
),
);
#$connection = DatabaseFactory::createConnection($config);
$db = new DatabaseManager($config);
$container->singleton(DatabaseManager::class, $db);
$container->singleton(ConnectionInterface::class, $db->getConnection());
return EntityManagerFactory::create($db);
}
}

View File

@@ -0,0 +1,69 @@
# Entity-Framework Beispiele
## Nutzung der ID-Generierung
Beispiele für das Speichern und Abrufen von Entities mit automatisch generierten IDs.
```php
// Neue Entity erstellen - ID wird automatisch generiert
$user = new User('Max Mustermann', 'max@example.com');
echo $user->id; // generierte ID, z.B. "7a3b9c2d1e4f5a6b8c9d0e1f2a3b4c5d"
// Entity speichern
$entityManager->save($user);
// Entity finden
$foundUser = $entityManager->find(User::class, $user->id);
// Entity aktualisieren (immutable update pattern)
$updatedUser = $foundUser->withName('Maximilian Mustermann');
$entityManager->save($updatedUser);
```
## Repository-Beispiele
```php
// Repository erstellen
$userRepository = new UserRepository($entityManager);
// Neuen User erstellen
$user = $userRepository->create('Anna Schmidt', 'anna@example.com');
// User suchen
$annaUser = $userRepository->findByEmail('anna@example.com');
// User aktualisieren
$updatedUser = $userRepository->changeName($annaUser, 'Anna Müller');
// User löschen
$userRepository->delete($updatedUser);
```
## Transaktionen
```php
$result = $entityManager->transaction(function($em) {
$user1 = new User('User 1', 'user1@example.com');
$user2 = new User('User 2', 'user2@example.com');
$em->save($user1);
$em->save($user2);
return [$user1, $user2];
});
```
## Entity-Beziehungen (kommt später)
```php
// One-to-Many Beziehung
$post = new Post('Titel', 'Inhalt', $user->id);
$entityManager->save($post);
// Many-to-Many Beziehung (später)
$tag = new Tag('php');
$entityManager->save($tag);
$postTag = new PostTag($post->id, $tag->id);
$entityManager->save($postTag);
```

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Example;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\IdGenerator;
/**
* Beispiel-Entity mit auto-generierter ID
*/
#[Entity('users')]
final readonly class User
{
#[Column(name: 'id', primary: true)]
public string $id;
#[Column(name: 'name')]
public string $name;
#[Column(name: 'email')]
public ?string $email;
public function __construct(
string $name,
?string $email = null,
?string $id = null
) {
$this->id = $id ?? IdGenerator::generate();
$this->name = $name;
$this->email = $email;
}
/**
* Erstellt eine neue Instanz mit geändertem Namen
*/
public function withName(string $name): self
{
return new self($name, $this->email, $this->id);
}
/**
* Erstellt eine neue Instanz mit geänderter Email
*/
public function withEmail(?string $email): self
{
return new self($this->name, $email, $this->id);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Example;
use App\Framework\Database\Repository\EntityRepository;
/**
* Beispiel für Repository mit User-Entity
*/
final class UserRepository extends EntityRepository
{
protected string $entityClass = User::class;
/**
* Findet User nach Email
*/
public function findByEmail(string $email): ?User
{
return $this->findOneBy(['email' => $email]);
}
/**
* Erstellt einen neuen User
*/
public function create(string $name, ?string $email = null): User
{
$user = new User($name, $email);
return $this->save($user);
}
/**
* Ändert den Namen eines Users
*/
public function changeName(User $user, string $newName): User
{
$updatedUser = $user->withName($newName);
return $this->save($updatedUser);
}
/**
* Ändert die Email eines Users
*/
public function changeEmail(User $user, ?string $newEmail): User
{
$updatedUser = $user->withEmail($newEmail);
return $this->save($updatedUser);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
class DatabaseException extends \Exception
{
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Exception;
class EntityNotFoundException extends DatabaseException
{
public function __construct(string $entityClass, mixed $id, ?\Throwable $previous = null)
{
parent::__construct("Entity {$entityClass} with ID {$id} not found", 0, $previous);
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\HealthCheck;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
final readonly class ConnectionHealthChecker
{
public function __construct(
private int $timeoutSeconds = 5,
private array $customQueries = []
) {}
/**
* Führt einen einfachen Health Check aus
*/
public function checkHealth(ConnectionInterface $connection): HealthCheckResult
{
$startTime = microtime(true);
try {
// Einfacher SELECT zum Testen der Verbindung
$result = $connection->queryScalar('SELECT 1');
$responseTime = (microtime(true) - $startTime) * 1000; // in Millisekunden
if ($result != 1) {
return HealthCheckResult::unhealthy(
$responseTime,
'Unexpected result from health check query',
null,
['expected' => 1, 'actual' => $result]
);
}
return HealthCheckResult::healthy($responseTime, 'Connection is healthy');
} catch (\Throwable $e) {
$responseTime = (microtime(true) - $startTime) * 1000;
return HealthCheckResult::error($e, $responseTime);
}
}
/**
* Führt einen Health Check mit Timeout aus
*/
public function checkHealthWithTimeout(ConnectionInterface $connection): HealthCheckResult
{
$startTime = time();
while ((time() - $startTime) < $this->timeoutSeconds) {
$result = $this->checkHealth($connection);
if ($result->isHealthy) {
return $result;
}
// Kurz warten vor nächstem Versuch
usleep(100000); // 100ms
}
return HealthCheckResult::timeout($this->timeoutSeconds * 1000);
}
/**
* Führt erweiterte Health Checks aus
*/
public function checkDetailedHealth(ConnectionInterface $connection): HealthCheckResult
{
$startTime = microtime(true);
$additionalData = [];
try {
// 1. Basis Health Check
$basicResult = $this->checkHealth($connection);
if (!$basicResult->isHealthy) {
return $basicResult;
}
$additionalData['basic_check'] = $basicResult->toArray();
// 2. PDO-Status prüfen
$pdoStatus = $this->checkPdoStatus($connection);
$additionalData['pdo_status'] = $pdoStatus;
// 3. Custom Queries ausführen
if (!empty($this->customQueries)) {
$customResults = $this->executeCustomQueries($connection);
$additionalData['custom_queries'] = $customResults;
}
// 4. Connection Attributes prüfen
$attributes = $this->getConnectionAttributes($connection);
$additionalData['connection_attributes'] = $attributes;
$responseTime = (microtime(true) - $startTime) * 1000;
return HealthCheckResult::healthy(
$responseTime,
'Detailed health check passed',
$additionalData
);
} catch (\Throwable $e) {
$responseTime = (microtime(true) - $startTime) * 1000;
return HealthCheckResult::error($e, $responseTime)->withAdditionalData('partial_data', $additionalData);
}
}
/**
* Prüft ob die Connection noch lebendig ist (schneller Check)
*/
public function checkConnectionAlive(ConnectionInterface $connection): bool
{
try {
$pdo = $connection->getPdo();
// Prüfe ob die PDO-Verbindung noch aktiv ist
$stmt = $pdo->query('SELECT 1');
return $stmt !== false;
} catch (\Throwable) {
return false;
}
}
/**
* Prüft den PDO-Status
*/
private function checkPdoStatus(ConnectionInterface $connection): array
{
try {
$pdo = $connection->getPdo();
return [
'connection_status' => $pdo->getAttribute(\PDO::ATTR_CONNECTION_STATUS),
'server_info' => $pdo->getAttribute(\PDO::ATTR_SERVER_INFO),
'driver_name' => $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME),
'client_version' => $pdo->getAttribute(\PDO::ATTR_CLIENT_VERSION),
'server_version' => $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION),
'in_transaction' => $connection->inTransaction(),
];
} catch (\Throwable $e) {
return ['error' => $e->getMessage()];
}
}
/**
* Führt benutzerdefinierte Queries aus
*/
private function executeCustomQueries(ConnectionInterface $connection): array
{
$results = [];
foreach ($this->customQueries as $name => $query) {
try {
$startTime = microtime(true);
$result = $connection->queryScalar($query);
$responseTime = (microtime(true) - $startTime) * 1000;
$results[$name] = [
'success' => true,
'result' => $result,
'response_time_ms' => round($responseTime, 2),
];
} catch (\Throwable $e) {
$results[$name] = [
'success' => false,
'error' => $e->getMessage(),
'response_time_ms' => 0,
];
}
}
return $results;
}
/**
* Sammelt Connection-Attribute
*/
private function getConnectionAttributes(ConnectionInterface $connection): array
{
try {
$pdo = $connection->getPdo();
$attributes = [];
$attributesToCheck = [
'AUTOCOMMIT' => \PDO::ATTR_AUTOCOMMIT,
'ERRMODE' => \PDO::ATTR_ERRMODE,
'CASE' => \PDO::ATTR_CASE,
'NULL_TO_STRING' => \PDO::ATTR_NULL_TO_STRING,
'STRINGIFY_FETCHES' => \PDO::ATTR_STRINGIFY_FETCHES,
'STATEMENT_CLASS' => \PDO::ATTR_STATEMENT_CLASS,
'TIMEOUT' => \PDO::ATTR_TIMEOUT,
'EMULATE_PREPARES' => \PDO::ATTR_EMULATE_PREPARES,
'DEFAULT_FETCH_MODE' => \PDO::ATTR_DEFAULT_FETCH_MODE,
];
foreach ($attributesToCheck as $name => $constant) {
try {
$attributes[$name] = $pdo->getAttribute($constant);
} catch (\Throwable) {
$attributes[$name] = 'Not supported';
}
}
return $attributes;
} catch (\Throwable $e) {
return ['error' => $e->getMessage()];
}
}
/**
* Factory-Methoden für verschiedene Health Check-Konfigurationen
*/
public static function quick(int $timeoutSeconds = 2): self
{
return new self($timeoutSeconds);
}
public static function detailed(int $timeoutSeconds = 10, array $customQueries = []): self
{
return new self($timeoutSeconds, $customQueries);
}
public static function withCustomQueries(array $customQueries, int $timeoutSeconds = 5): self
{
return new self($timeoutSeconds, $customQueries);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\HealthCheck;
final readonly class HealthCheckResult
{
public function __construct(
public bool $isHealthy,
public float $responseTimeMs,
public ?string $message = null,
public ?\Throwable $exception = null,
public array $additionalData = []
) {}
public function toArray(): array
{
return [
'healthy' => $this->isHealthy,
'response_time_ms' => round($this->responseTimeMs, 2),
'message' => $this->message,
'error' => $this->exception?->getMessage(),
'error_code' => $this->exception?->getCode(),
'additional_data' => $this->additionalData,
'timestamp' => time(),
];
}
public function toJson(): string
{
return json_encode($this->toArray(), JSON_THROW_ON_ERROR);
}
public function withAdditionalData(string $key, mixed $value): self
{
return new self(
$this->isHealthy,
$this->responseTimeMs,
$this->message,
$this->exception,
array_merge($this->additionalData, [$key => $value])
);
}
public function getAdditionalData(string $key, mixed $default = null): mixed
{
return $this->additionalData[$key] ?? $default;
}
public function hasAdditionalData(string $key): bool
{
return array_key_exists($key, $this->additionalData);
}
public static function healthy(float $responseTimeMs, ?string $message = null, array $additionalData = []): self
{
return new self(true, $responseTimeMs, $message, null, $additionalData);
}
public static function unhealthy(float $responseTimeMs, string $message, ?\Throwable $exception = null, array $additionalData = []): self
{
return new self(false, $responseTimeMs, $message, $exception, $additionalData);
}
public static function timeout(float $timeoutMs): self
{
return self::unhealthy($timeoutMs, "Health check timed out after {$timeoutMs}ms");
}
public static function error(\Throwable $exception, float $responseTimeMs = 0): self
{
return self::unhealthy($responseTimeMs, $exception->getMessage(), $exception);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\HealthCheck;
use App\Framework\Database\ConnectionInterface;
final class HealthCheckScheduler
{
private array $healthChecks = [];
private array $lastResults = [];
public function __construct(
private readonly ConnectionHealthChecker $healthChecker
) {}
/**
* Registriert einen Health Check
*/
public function registerHealthCheck(
string $name,
ConnectionInterface $connection,
int $intervalSeconds = 30,
bool $detailed = false
): self {
$this->healthChecks[$name] = [
'connection' => $connection,
'interval' => $intervalSeconds,
'detailed' => $detailed,
'last_check' => 0,
];
return $this;
}
/**
* Führt alle fälligen Health Checks aus
*/
public function runScheduledChecks(): array
{
$results = [];
$currentTime = time();
foreach ($this->healthChecks as $name => $config) {
if (($currentTime - $config['last_check']) >= $config['interval']) {
$result = $this->runHealthCheck($name, $config);
$results[$name] = $result;
$this->lastResults[$name] = $result;
$this->healthChecks[$name]['last_check'] = $currentTime;
}
}
return $results;
}
/**
* Führt einen spezifischen Health Check aus
*/
public function runHealthCheck(string $name, ?array $config = null): HealthCheckResult
{
$config = $config ?? $this->healthChecks[$name] ?? null;
if (!$config) {
return HealthCheckResult::error(
new \InvalidArgumentException("Health check '{$name}' not found")
);
}
try {
if ($config['detailed']) {
return $this->healthChecker->checkDetailedHealth($config['connection']);
} else {
return $this->healthChecker->checkHealth($config['connection']);
}
} catch (\Throwable $e) {
return HealthCheckResult::error($e);
}
}
/**
* Gibt alle letzten Health Check-Ergebnisse zurück
*/
public function getAllResults(): array
{
return $this->lastResults;
}
/**
* Gibt das letzte Ergebnis für einen spezifischen Health Check zurück
*/
public function getLastResult(string $name): ?HealthCheckResult
{
return $this->lastResults[$name] ?? null;
}
/**
* Prüft ob alle registrierten Connections gesund sind
*/
public function areAllHealthy(): bool
{
foreach ($this->lastResults as $result) {
if (!$result->isHealthy) {
return false;
}
}
return true;
}
/**
* Gibt eine Zusammenfassung aller Health Checks zurück
*/
public function getSummary(): array
{
$total = count($this->healthChecks);
$healthy = 0;
$unhealthy = 0;
$unknown = 0;
foreach ($this->healthChecks as $name => $config) {
if (isset($this->lastResults[$name])) {
if ($this->lastResults[$name]->isHealthy) {
$healthy++;
} else {
$unhealthy++;
}
} else {
$unknown++;
}
}
return [
'total_checks' => $total,
'healthy' => $healthy,
'unhealthy' => $unhealthy,
'unknown' => $unknown,
'overall_status' => $unhealthy === 0 ? 'healthy' : 'unhealthy',
'last_updated' => time(),
];
}
/**
* Entfernt einen Health Check
*/
public function unregisterHealthCheck(string $name): self
{
unset($this->healthChecks[$name], $this->lastResults[$name]);
return $this;
}
/**
* Löscht alle Health Checks
*/
public function clear(): self
{
$this->healthChecks = [];
$this->lastResults = [];
return $this;
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\PropertyMetadata;
final readonly class Hydrator
{
public function __construct(
private TypeConverter $typeConverter,
private EntityManager $entityManager,
) {}
public function hydrate(EntityMetadata $metadata, array $data): object
{
try {
$constructor = $metadata->reflection->getConstructor();
if (!$constructor) {
throw new DatabaseException("Entity {$metadata->entityClass} must have a constructor");
}
$constructorParams = [];
foreach ($constructor->getParameters() as $param) {
$paramName = $param->getName();
$propertyMetadata = $metadata->getProperty($paramName);
if (!$propertyMetadata) {
throw new DatabaseException("No metadata found for parameter {$paramName}");
}
$columnName = $propertyMetadata->columnName;
$value = $data[$columnName] ?? null;
// Typ-Konvertierung
$value = $this->typeConverter->convertValue($value, $propertyMetadata);
$constructorParams[] = $value;
}
$entity = new $metadata->entityClass(...$constructorParams);
$this->hydrateRelations($entity, $metadata, $data);
return $entity;
} catch (\ReflectionException $e) {
throw new DatabaseException("Failed to create entity {$metadata->entityClass}: {$e->getMessage()}", 0, $e);
}
}
private function hydrateRelations(object $entity, EntityMetadata $metadata, array $data): void
{
foreach ($metadata->properties as $propertyMetadata) {
if (!$propertyMetadata->isRelation) {
continue;
}
$relationValue = $this->loadRelation($propertyMetadata, $data);
if ($relationValue !== null) {
$this->setProperty($entity, $propertyMetadata->name, $relationValue);
}
}
}
private function loadRelation(PropertyMetadata $propertyMetadata, array $data): mixed
{
$keyValue = $this->getRelationKeyValue($propertyMetadata, $data);
if($keyValue === null) {
return $this->getDefaultRelationValue($propertyMetadata);
}
switch($propertyMetadata->relationType) {
case 'one-to-one':
return $this->loadOneToOneRelation($propertyMetadata, $keyValue);
case 'hasMany':
return $this->loadHasManyRelation($propertyMetadata, $keyValue);
case 'belongsTo':
return $this->loadBelongsToRelation($propertyMetadata, $keyValue);
default:
throw new DatabaseException("Unknown relation type {$propertyMetadata->relationType}");
}
}
private function getRelationKeyValue(PropertyMetadata $propertyMetadata, array $data): mixed
{
return match($propertyMetadata->relationType) {
'one-to-one' => $data[$propertyMetadata->relationLocalKey] ?? null,
'hasMany' => $data[$propertyMetadata->relationLocalKey] ?? null,
'belongsTo' => $data[$propertyMetadata->relationForeignKey] ?? null,
default => null
};
}
private function loadOneToOneRelation(PropertyMetadata $propertyMetadata, mixed $localKeyValue):?object
{
return $this->entityManager->findOneBy(
$propertyMetadata->relationTargetClass,
[$this->getForeignKeyPropertyName($propertyMetadata) => $localKeyValue]
);
}
private function loadHasManyRelation(PropertyMetadata $propertyMetadata, mixed $localKeyValue): array
{
return $this->entityManager->findBy(
$propertyMetadata->relationTargetClass,
[$this->getForeignKeyPropertyName($propertyMetadata) => $localKeyValue]
);
}
private function loadBelongsToRelation(PropertyMetadata $propertyMetadata, mixed $foreignKeyValue):?object
{
if($foreignKeyValue === null) {
return null;
}
return $this->entityManager->find(
$propertyMetadata->relationTargetClass,
$foreignKeyValue
);
}
private function getForeignKeyPropertyName(PropertyMetadata $propertyMetadata): string
{
return $propertyMetadata->relationForeignKey;
}
private function getDefaultRelationValue(PropertyMetadata $propertyMetadata): mixed
{
return match($propertyMetadata->relationType) {
'one-to-one' => null,
'hasMany' => [],
'belongsTo' => null,
default => null
};
}
private function setProperty(object $entity, string $propertyName, mixed $value): void
{
$reflection = new \ReflectionClass($entity);
$property = $reflection->getProperty($propertyName);
// Property zugänglich machen (falls private/protected)
$property->setValue($entity, $value);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
/**
* Generator für eindeutige Entity-IDs
*/
final class IdGenerator
{
/**
* Generiert eine zufällige ID
*/
public static function generate(): string
{
return bin2hex(random_bytes(16)); // 32 character hex string
}
/**
* Prüft ob eine ID gültig ist
*/
public static function isValid(string $id): bool
{
return strlen($id) === 32 && ctype_xdigit($id);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
/**
* IdentityMap is a pattern implementation that provides a way to manage
* and track instances of entities by their unique identifiers within a single context.
* It helps prevent duplicate instances of the same entity in memory.
*/
final class IdentityMap
{
private array $entities = [];
public function get(string $entityClass, mixed $id): ?object
{
$key = $this->createKey($entityClass, $id);
return $this->entities[$key] ?? null;
}
public function set(string $entityClass, mixed $id, object $entity): void
{
$key = $this->createKey($entityClass, $id);
$this->entities[$key] = $entity;
}
public function has(string $entityClass, mixed $id): bool
{
$key = $this->createKey($entityClass, $id);
return isset($this->entities[$key]);
}
public function remove(string $entityClass, mixed $id): void
{
$key = $this->createKey($entityClass, $id);
unset($this->entities[$key]);
}
public function clear(): void
{
$this->entities = [];
}
public function getStats(): array
{
return [
'total_entities' => count($this->entities),
'entity_types' => array_count_values(
array_map(fn($key) => explode('#', $key)[0], array_keys($this->entities))
)
];
}
private function createKey(string $entityClass, mixed $id): string
{
return $entityClass . '#' . $id;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Exception\DatabaseException;
use ReflectionClass;
final readonly class LazyConnectionFactory
{
public static function createLazyGhost(
array|DriverConfig $config,
string $connectionClass = PdoConnection::class
): ConnectionInterface {
$reflection = new ReflectionClass($connectionClass);
// Stelle sicher, dass die Klasse ConnectionInterface implementiert
if (!$reflection->implementsInterface(ConnectionInterface::class)) {
throw new DatabaseException(
"Class {$connectionClass} must implement ConnectionInterface"
);
}
// Erstelle LazyGhost mit Initializer
$ghost = $reflection->newLazyGhost(
initializer: function (object $object) use ($config, $connectionClass): void {
self::initializeLazyConnection($object, $config, $connectionClass);
}
);
return $ghost;
}
private static function initializeLazyConnection(
object $ghost,
array|DriverConfig $config,
string $connectionClass
): void {
// Erstelle die echte Verbindung
$realConnection = DatabaseFactory::createDirectConnection($config);
// Kopiere alle Properties von der echten Verbindung in das Ghost
$ghostReflection = new ReflectionClass($ghost);
$realReflection = new ReflectionClass($realConnection);
// Für PdoConnection: kopiere die PDO-Instanz
if ($realConnection instanceof PdoConnection) {
$pdoProperty = $realReflection->getProperty('pdo');
$ghostPdoProperty = $ghostReflection->getProperty('pdo');
$ghostPdoProperty->setValue($ghost, $pdoProperty->getValue($realConnection));
}
// Zusätzliche Properties falls vorhanden
foreach ($realReflection->getProperties() as $property) {
if ($ghostReflection->hasProperty($property->getName())) {
$ghostProperty = $ghostReflection->getProperty($property->getName());
$ghostProperty->setValue($ghost, $property->getValue($realConnection));
}
}
}
public static function isLazyGhost(object $object): bool
{
$reflection = new ReflectionClass($object);
return $reflection->isUninitializedLazyObject($object);
}
public static function initializeLazyGhost(object $object): void
{
$reflection = new ReflectionClass($object);
if ($reflection->isUninitializedLazyObject($object)) {
$reflection->initializeLazyObject($object);
}
}
public static function resetLazyGhost(object $object): void
{
$reflection = new ReflectionClass($object);
if (!$reflection->isUninitializedLazyObject($object)) {
$reflection->resetAsLazyGhost($object, function (object $obj) use ($object): void {
// Re-initialize with same logic as original
// This would require storing the original config somewhere
throw new DatabaseException('LazyGhost reset requires stored configuration');
});
}
}
}

View File

@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\MetadataRegistry;
use ReflectionClass;
final readonly class LazyLoader
{
public function __construct(
private DatabaseManager $databaseManager,
private TypeConverter $typeConverter,
private IdentityMap $identityMap,
private MetadataRegistry $metadataRegistry
) {}
/**
* Erstellt ein Lazy Ghost Object mit dynamischen Relationen
*/
public function createLazyGhost(EntityMetadata $metadata, mixed $id): object
{
// Prüfe zuerst Identity Map
if ($this->identityMap->has($metadata->entityClass, $id)) {
return $this->identityMap->get($metadata->entityClass, $id);
}
$reflection = $metadata->reflection;
// Für readonly Klassen müssen wir newLazyProxy verwenden
if ($reflection->isReadOnly()) {
return $this->createLazyProxy($metadata, $id);
}
// Erstelle Lazy Ghost mit dynamischen Relationen
$ghost = $reflection->newLazyGhost(
initializer: function (object $object) use ($metadata, $id): void {
$this->initializeGhostWithDynamicRelations($object, $metadata, $id);
}
);
// In Identity Map speichern
$this->identityMap->set($metadata->entityClass, $id, $ghost);
return $ghost;
}
/**
* Erstellt einen Lazy Proxy für readonly Klassen
*/
private function createLazyProxy(EntityMetadata $metadata, mixed $id): object
{
$reflection = $metadata->reflection;
$proxy = $reflection->newLazyProxy(
factory: function () use ($metadata, $id): object {
return $this->loadEntityWithDynamicRelations($metadata, $id);
}
);
// In Identity Map speichern
$this->identityMap->set($metadata->entityClass, $id, $proxy);
return $proxy;
}
/**
* Lädt eine Entity mit dynamischen Relationen
*/
private function loadEntityWithDynamicRelations(EntityMetadata $metadata, mixed $id): object
{
// Basis-Entity laden
$entity = $this->loadEntity($metadata, $id);
// Dynamische Relationen laden
$this->loadDynamicRelations($entity, $metadata, $id);
return $entity;
}
/**
* Initialisiert das Ghost Object mit dynamischen Relationen
*/
private function initializeGhostWithDynamicRelations(object $ghost, EntityMetadata $metadata, mixed $id): void
{
// Basis-Daten laden
$this->initializeGhost($ghost, $metadata, $id);
// Dynamische Relationen laden
$this->loadDynamicRelations($ghost, $metadata, $id);
}
/**
* Lädt alle definierten Relationen dynamisch basierend auf Attributen
*/
private function loadDynamicRelations(object $entity, EntityMetadata $metadata, mixed $id): void
{
foreach ($metadata->properties as $propertyName => $propertyMetadata) {
if (!$propertyMetadata->isRelation) {
continue;
}
$this->loadRelation($entity, $propertyMetadata, $id);
}
}
/**
* Lädt eine spezifische Relation basierend auf dem Relation-Typ
*/
private function loadRelation(object $entity, $propertyMetadata, mixed $localKeyValue): void
{
try {
$reflection = new ReflectionClass($entity);
$property = $reflection->getProperty($propertyMetadata->name);
// Relation nur laden wenn noch nicht initialisiert
if (!$property->isInitialized($entity)) {
$relationValue = $this->loadRelationByType($propertyMetadata, $entity, $localKeyValue);
$property->setValue($entity, $relationValue);
}
} catch (\ReflectionException $e) {
// Property nicht vorhanden oder nicht zugänglich
}
}
/**
* Lädt Relation basierend auf dem Typ
*/
private function loadRelationByType($propertyMetadata, object $entity, mixed $localKeyValue): mixed
{
switch ($propertyMetadata->relationType) {
case 'belongsTo':
return $this->loadBelongsToRelation($propertyMetadata, $entity);
case 'hasMany':
return $this->loadHasManyRelation($propertyMetadata, $localKeyValue);
case 'one-to-one':
return $this->loadOneToOneRelation($propertyMetadata, $localKeyValue);
default:
return null;
}
}
/**
* Lädt eine belongsTo Relation
*/
private function loadBelongsToRelation($propertyMetadata, object $entity): ?object
{
// Für belongsTo müssen wir den Foreign Key Wert aus der Entity selbst holen
$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]);
$data = $result->fetch();
if (!$data) {
return null;
}
// Hole den Foreign Key Wert
$foreignKeyValue = $data[$propertyMetadata->relationForeignKey] ?? null;
if ($foreignKeyValue === null) {
return null;
}
// Lade die verknüpfte Entity
$targetMetadata = $this->metadataRegistry->getMetadata($propertyMetadata->relationTargetClass);
if ($this->identityMap->has($propertyMetadata->relationTargetClass, $foreignKeyValue)) {
return $this->identityMap->get($propertyMetadata->relationTargetClass, $foreignKeyValue);
}
$query = "SELECT * FROM {$targetMetadata->tableName} WHERE {$targetMetadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$foreignKeyValue]);
$targetData = $result->fetch();
if (!$targetData) {
return null;
}
$targetEntity = $this->hydrateEntity($targetMetadata, $targetData);
$this->identityMap->set($propertyMetadata->relationTargetClass, $foreignKeyValue, $targetEntity);
return $targetEntity;
}
/**
* Lädt eine hasMany Relation
*/
private function loadHasManyRelation($propertyMetadata, mixed $localKeyValue): array
{
$relatedData = $this->queryRelatedData(
$propertyMetadata->relationTargetClass,
$propertyMetadata->relationForeignKey,
$localKeyValue
);
return $this->hydrateRelatedEntities(
$propertyMetadata->relationTargetClass,
$relatedData
);
}
/**
* Lädt eine one-to-one Relation
*/
private function loadOneToOneRelation($propertyMetadata, mixed $localKeyValue): ?object
{
$relatedData = $this->queryRelatedData(
$propertyMetadata->relationTargetClass,
$propertyMetadata->relationForeignKey,
$localKeyValue
);
if (empty($relatedData)) {
return null;
}
// Für one-to-one nur das erste Ergebnis zurückgeben
$entities = $this->hydrateRelatedEntities(
$propertyMetadata->relationTargetClass,
[$relatedData[0]] // Nur das erste Element
);
return $entities[0] ?? null;
}
/**
* Ermittelt die ID einer Entity
*/
private function getEntityId(object $entity, EntityMetadata $metadata): mixed
{
try {
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
return $idProperty->getValue($entity);
} catch (\ReflectionException) {
// Fallback: versuche über Constructor-Parameter
return null;
}
}
/**
* Lädt verwandte Daten aus der Datenbank
*/
private function queryRelatedData(string $targetClass, string $foreignKey, mixed $localKeyValue): array
{
$targetMetadata = $this->metadataRegistry->getMetadata($targetClass);
$query = "SELECT * FROM {$targetMetadata->tableName} WHERE {$foreignKey} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$localKeyValue]);
return $result->fetchAll();
}
/**
* Hydratisiert verwandte Entities
*/
private function hydrateRelatedEntities(string $targetClass, array $dataRows): array
{
$entities = [];
$targetMetadata = $this->metadataRegistry->getMetadata($targetClass);
foreach ($dataRows as $data) {
// Prüfe Identity Map
$idValue = $data[$targetMetadata->idColumn];
if ($this->identityMap->has($targetClass, $idValue)) {
$entities[] = $this->identityMap->get($targetClass, $idValue);
} else {
$entity = $this->hydrateEntity($targetMetadata, $data);
$this->identityMap->set($targetClass, $idValue, $entity);
$entities[] = $entity;
}
}
return $entities;
}
/**
* Hydratisiert eine einzelne Entity
*/
private function hydrateEntity(EntityMetadata $metadata, array $data): object
{
$constructor = $metadata->reflection->getConstructor();
if (!$constructor) {
throw new \RuntimeException("Entity {$metadata->entityClass} must have a constructor");
}
$constructorParams = [];
foreach ($constructor->getParameters() as $param) {
$paramName = $param->getName();
$propertyMetadata = $metadata->getProperty($paramName);
if (!$propertyMetadata) {
throw new \RuntimeException("No metadata found for parameter {$paramName}");
}
$columnName = $propertyMetadata->columnName;
$value = $data[$columnName] ?? null;
// Typ-Konvertierung
$convertedValue = $this->typeConverter->convertValue($value, $propertyMetadata);
$constructorParams[] = $convertedValue;
}
return new $metadata->entityClass(...$constructorParams);
}
/**
* Lädt eine Entity aus der Datenbank
*/
private function loadEntity(EntityMetadata $metadata, mixed $id): object
{
$query = "SELECT * FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
$data = $result->fetch();
if (!$data) {
throw new Exception\EntityNotFoundException($metadata->entityClass, $id);
}
return $this->hydrateEntity($metadata, $data);
}
/**
* Initialisiert das Ghost Object (nur für non-readonly Klassen)
*/
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]);
$data = $result->fetch();
if (!$data) {
throw new Exception\EntityNotFoundException($metadata->entityClass, $id);
}
// Ghost Object Properties setzen
foreach ($metadata->properties as $propertyName => $propertyMetadata) {
$columnName = $propertyMetadata->columnName;
$value = $data[$columnName] ?? null;
// Typ-Konvertierung
$convertedValue = $this->typeConverter->convertValue($value, $propertyMetadata);
// Property setzen
try {
$property = $metadata->reflection->getProperty($propertyName);
$property->setValue($ghost, $convertedValue);
} catch (\ReflectionException) {
// Property nicht zugänglich oder existiert nicht
}
}
}
/**
* Prüft ob ein Object ein uninitializiertes Lazy Object ist
*/
public function isLazyGhost(object $object): bool
{
$reflection = new ReflectionClass($object);
return $reflection->isUninitializedLazyObject($object);
}
/**
* Forciert die Initialisierung eines Lazy Objects
*/
public function initializeLazyObject(object $object): void
{
$reflection = new ReflectionClass($object);
if ($reflection->isUninitializedLazyObject($object)) {
$reflection->initializeLazyObject($object);
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Metadata;
use ReflectionClass;
final readonly class EntityMetadata
{
public function __construct(
public string $entityClass,
public string $tableName,
public string $idColumn,
public string $idProperty,
public ReflectionClass $reflection,
public array $properties
) {}
public function getProperty(string $name): ?PropertyMetadata
{
return $this->properties[$name] ?? null;
}
public function getColumnName(string $propertyName): string
{
$property = $this->getProperty($propertyName);
return $property?->columnName ?? $propertyName;
}
}

View File

@@ -0,0 +1,501 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Metadata;
use App\Framework\Database\Attributes\Entity;
use App\Framework\Database\Attributes\Column;
use App\Framework\Database\Attributes\Type;
use App\Framework\Database\Exception\DatabaseException;
use ReflectionClass;
use ReflectionProperty;
use ReflectionParameter;
use ReflectionNamedType;
use ReflectionUnionType;
final class MetadataExtractor
{
public function extractMetadata(string $entityClass): EntityMetadata
{
try {
$reflection = new ReflectionClass($entityClass);
$entityAttribute = $this->getEntityAttribute($reflection);
if (!$entityAttribute) {
throw new DatabaseException("Class {$entityClass} is not marked as Entity");
}
$tableName = $entityAttribute->tableName ?? $this->getTableNameFromClass($entityClass);
$idColumn = $entityAttribute->idColumn ?? 'id';
$properties = $this->extractProperties($reflection);
$relations = $this->extractRelations($reflection);
$idProperty = $this->findIdProperty($reflection, $properties, $idColumn);
return new EntityMetadata(
entityClass: $entityClass,
tableName: $tableName,
idColumn: $idColumn,
idProperty: $idProperty,
reflection: $reflection,
properties: array_merge($properties, $relations)
);
} catch (\ReflectionException $e) {
throw new DatabaseException("Failed to analyze entity {$entityClass}: {$e->getMessage()}", 0, $e);
}
}
private function extractProperties(ReflectionClass $reflection): array
{
$properties = [];
$constructor = $reflection->getConstructor();
if (!$constructor) {
return $properties;
}
foreach ($constructor->getParameters() as $param) {
$propertyMetadata = $this->extractPropertyMetadata($param, $reflection);
$properties[$param->getName()] = $propertyMetadata;
}
return $properties;
}
/**
* Extrahiert Relation-Properties die nicht im Constructor sind
*/
private function extractRelations(ReflectionClass $reflection): array
{
$relations = [];
foreach ($reflection->getProperties() as $property) {
if ($property->isPromoted()) {
continue; // Überspringen, da bereits in extractProperties erfasst
}
$typeAttribute = $this->getTypeAttribute($property);
if ($typeAttribute) {
$relationMetadata = $this->extractRelationMetadata($property, $typeAttribute, $reflection);
$relations[$property->getName()] = $relationMetadata;
continue;
}
// Prüfe ob der Property-Typ eine Entity-Klasse ist
$propertyType = $property->getType();
if ($propertyType instanceof \ReflectionNamedType) {
$typeName = $propertyType->getName();
// Prüfe ob die Typ-Klasse eine Entity ist
if (class_exists($typeName) && $this->isEntityClass($typeName)) {
$relationMetadata = $this->createRelationMetadataForEntityProperty($property, $typeName, $reflection);
$relations[$property->getName()] = $relationMetadata;
continue;
}
}
}
return $relations;
}
private function isEntityClass(string $className): bool
{
try {
$classReflection = new \ReflectionClass($className);
return $this->getEntityAttribute($classReflection) !== null;
} catch (\ReflectionException) {
return false;
}
}
private function createRelationMetadataForEntityProperty(
\ReflectionProperty $property,
string $targetClass,
\ReflectionClass $parentReflection
): PropertyMetadata {
$propertyName = $property->getName();
$relationType = $this->determineRelationType($property);
if($relationType === 'belongsTo') {
return $this->createBelongsToRelationMetadata($property, $targetClass, $parentReflection);
}
return $this->createHasRelationMetadata($property, $targetClass, $parentReflection, $relationType);
/*
// Foreign Key ist der Primary Key der Ziel-Entity
$targetReflection = new \ReflectionClass($targetClass);
$targetEntityAttribute = $this->getEntityAttribute($targetReflection);
$foreignKey = $targetEntityAttribute?->idColumn ?? 'id';
// Local Key ist die entsprechende Spalte in der aktuellen Entity
// Für "image" Property -> "image_id"
$localKey = $propertyName . '_id';
// Typ-Analyse der Property
$type = $property->getType();
$typeInfo = $this->analyzeType($type);
return new PropertyMetadata(
name: $propertyName,
columnName: '',
type: $typeInfo['mainType'],
nullable: $typeInfo['nullable'],
hasDefault: true,
defaultValue: null,
allTypes: $typeInfo['allTypes'],
primary: false,
autoIncrement: false,
isRelation: true,
relationTargetClass: $targetClass,
relationForeignKey: $foreignKey,
relationLocalKey: $localKey,
relationType: 'one-to-one'
);*/
}
public function determineRelationType(\ReflectionProperty $property): string
{
$propertyType = $property->getType();
if($propertyType instanceof \ReflectionNamedType && $propertyType->getName() === 'array') {
return 'hasMany';
}
$parentReflection = $property->getDeclaringClass();
$possibleForeignKeyProperty = $property->getName() . 'Id';
if($this->hasConstructorParameter($parentReflection, $possibleForeignKeyProperty)) {
return 'belongsTo';
}
$possibleForeignKeyProperty2 = $property->getName() . '_id';
if($this->hasConstructorParameter($parentReflection, $possibleForeignKeyProperty2)) {
return 'belongsTo';
}
return 'one-to-one';
}
private function hasConstructorParameter(ReflectionClass $reflection, string $paramName): bool
{
$constructor = $reflection->getConstructor();
if(!$constructor) {
return false;
}
foreach($constructor->getParameters() as $param) {
if($param->getName() === $paramName) {
return true;
}
}
return false;
}
private function createBelongsToRelationMetadata(\ReflectionProperty $property, string $targetClass, ReflectionClass $parentReflection): PropertyMetadata
{
$propertyName = $property->getName();
$foreignKeyProperty = $this->findForeignKeyProperty($propertyName, $parentReflection);
if(!$foreignKeyProperty) {
throw new DatabaseException("Could not find foreign key property for property {$propertyName} in class {$parentReflection->getName()}");
}
$foreignKeyColumnName = $this->getColumnName($foreignKeyProperty, $parentReflection);
$type = $property->getType();
$typeInfo = $this->analyzeType($type);
return new PropertyMetadata(
name: $propertyName,
columnName: '',
type: $typeInfo['mainType'],
nullable: $typeInfo['nullable'],
hasDefault: true,
defaultValue: null,
allTypes: $typeInfo['allTypes'],
primary: false,
autoIncrement: false,
isRelation: true,
relationTargetClass: $targetClass,
relationForeignKey: $foreignKeyColumnName, // Jetzt die Spalte, nicht der Parameter-Name
relationLocalKey: '', // Wird in belongsTo nicht verwendet
relationType: 'belongsTo'
);
}
private function findForeignKeyProperty(string $propertyName, ReflectionClass $parentReflection): ?string
{
$constructor = $parentReflection->getConstructor();
if (!$constructor) {
return null;
}
// Suche nach {property}Id
foreach ($constructor->getParameters() as $param) {
if ($param->getName() === $propertyName . 'Id') {
return $param->getName();
}
}
// Suche nach {property}_id (falls das der Parameter-Name ist)
foreach ($constructor->getParameters() as $param) {
if ($param->getName() === $propertyName . '_id') {
return $param->getName();
}
}
return null;
}
private function createHasRelationMetadata(\ReflectionProperty $property, string $targetClass, ReflectionClass $parentReflection, string $relationType): PropertyMetadata
{
$propertyName = $property->getName();
$targetReflection = new ReflectionClass($targetClass);
$targetEntityAttribute = $this->getEntityAttribute($targetReflection);
$foreignKey = $targetEntityAttribute?->idColumn ?? 'id';
$entityAttribute = $this->getEntityAttribute($parentReflection);
$localKey = $entityAttribute?->idColumn ?? 'id';
$type = $property->getType();
$typeInfo = $this->analyzeType($type);
return new PropertyMetadata(
name: $propertyName,
columnName: '',
type: $typeInfo['mainType'],
nullable: $typeInfo['nullable'],
hasDefault: true,
defaultValue: $relationType === 'hasMany' ? [] : null,
allTypes: $typeInfo['allTypes'],
primary: false,
autoIncrement: false,
isRelation: true,
relationTargetClass: $targetClass,
relationForeignKey: $foreignKey,
relationLocalKey: $localKey,
relationType: $relationType
);
}
private function extractRelationMetadata(ReflectionProperty $property, Type $typeAttribute, ReflectionClass $classReflection): PropertyMetadata
{
$propertyName = $property->getName();
// Ermittle foreign key automatisch falls nicht gesetzt
$foreignKey = $typeAttribute->foreignKey;
if (!$foreignKey) {
// Konvention: {parent_class}_id
$shortName = strtolower($classReflection->getShortName());
$foreignKey = $shortName . '_id';
}
// Ermittle local key automatisch falls nicht gesetzt
$localKey = $typeAttribute->localKey;
if (!$localKey) {
// Verwende ID-Property der Entity
$entityAttribute = $this->getEntityAttribute($classReflection);
$localKey = $entityAttribute?->idColumn ?? 'id';
}
// Typ-Analyse der Property
$type = $property->getType();
$typeInfo = $this->analyzeType($type);
return new PropertyMetadata(
name: $propertyName,
columnName: '', // Relationen haben keine direkte Spalte
type: $typeInfo['mainType'],
nullable: $typeInfo['nullable'],
hasDefault: true, // Relationen haben immer einen Default (leeres Array)
defaultValue: [],
allTypes: $typeInfo['allTypes'],
primary: false,
autoIncrement: false,
isRelation: true,
relationTargetClass: $typeAttribute->targetClass,
relationForeignKey: $foreignKey,
relationLocalKey: $localKey,
relationType: $typeAttribute->type
);
}
private function extractPropertyMetadata(ReflectionParameter $param, ReflectionClass $classReflection): PropertyMetadata
{
$paramName = $param->getName();
// Column-Name aus Attribute oder Parameter-Name
$columnName = $this->getColumnName($paramName, $classReflection);
// Typ-Analyse
$type = $param->getType();
$typeInfo = $this->analyzeType($type);
// Default-Werte und Nullable
$hasDefault = $param->isDefaultValueAvailable();
$defaultValue = $hasDefault ? $param->getDefaultValue() : null;
$nullable = $typeInfo['nullable'] || $hasDefault;
// Primary Key und Auto-Increment Eigenschaften aus Attribut auslesen
$columnAttribute = null;
$primary = false;
$autoIncrement = false;
try {
$property = $classReflection->getProperty($paramName);
$columnAttribute = $this->getColumnAttribute($property);
if ($columnAttribute) {
$primary = $columnAttribute->primary;
$autoIncrement = $columnAttribute->autoIncrement;
if ($columnAttribute->nullable) {
$nullable = true;
}
}
} catch (\ReflectionException) {
// Property nicht gefunden
}
return new PropertyMetadata(
name: $paramName,
columnName: $columnName,
type: $typeInfo['mainType'],
nullable: $nullable,
hasDefault: $hasDefault,
defaultValue: $defaultValue,
allTypes: $typeInfo['allTypes'],
primary: $primary,
autoIncrement: $autoIncrement
);
}
private function getColumnName(string $paramName, ReflectionClass $classReflection): string
{
try {
$property = $classReflection->getProperty($paramName);
$columnAttribute = $this->getColumnAttribute($property);
return $columnAttribute?->name ?? $paramName;
} catch (\ReflectionException) {
return $paramName;
}
}
private function analyzeType(?\ReflectionType $type): array
{
if (!$type) {
return [
'mainType' => 'mixed',
'allTypes' => ['mixed'],
'nullable' => true
];
}
if ($type instanceof ReflectionNamedType) {
return [
'mainType' => $type->getName(),
'allTypes' => [$type->getName()],
'nullable' => $type->allowsNull()
];
}
if ($type instanceof ReflectionUnionType) {
$types = [];
$nullable = false;
foreach ($type->getTypes() as $unionType) {
if ($unionType instanceof ReflectionNamedType) {
$typeName = $unionType->getName();
if ($typeName === 'null') {
$nullable = true;
} else {
$types[] = $typeName;
}
}
}
return [
'mainType' => $types[0] ?? 'mixed',
'allTypes' => $types,
'nullable' => $nullable
];
}
return [
'mainType' => 'mixed',
'allTypes' => ['mixed'],
'nullable' => true
];
}
private function getEntityAttribute(ReflectionClass $reflection): ?Entity
{
$attributes = $reflection->getAttributes(Entity::class);
return $attributes ? $attributes[0]->newInstance() : null;
}
private function getColumnAttribute(ReflectionProperty $property): ?Column
{
$attributes = $property->getAttributes(Column::class);
return $attributes ? $attributes[0]->newInstance() : null;
}
private function getTypeAttribute(ReflectionProperty $property): ?Type
{
$attributes = $property->getAttributes(Type::class);
return $attributes ? $attributes[0]->newInstance() : null;
}
private function getTableNameFromClass(string $className): string
{
$shortName = new ReflectionClass($className)->getShortName();
return strtolower($shortName) . 's';
}
/**
* Ermittelt den Property-Namen, der als ID dient
*/
private function findIdProperty(ReflectionClass $reflection, array $properties, string $idColumn): string
{
// 1. Suche nach Property mit primary=true im Column-Attribut
foreach ($properties as $propName => $propMetadata) {
try {
$property = $reflection->getProperty($propName);
$columnAttribute = $this->getColumnAttribute($property);
if ($columnAttribute && $columnAttribute->primary) {
return $propName;
}
} catch (\ReflectionException) {
// Property nicht gefunden, ignorieren
}
}
// 2. Suche nach Property, die dem idColumn entspricht
foreach ($properties as $propName => $propMetadata) {
if ($propMetadata->columnName === $idColumn) {
return $propName;
}
}
// 3. Fallback: Property mit Namen 'id'
if (isset($properties['id'])) {
return 'id';
}
// 4. Wenn nichts passt: Erste Property (nicht ideal, aber besser als nichts)
if (!empty($properties)) {
return array_key_first($properties);
}
throw new DatabaseException("Keine ID-Property für Entity {$reflection->getName()} gefunden");
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Metadata;
final class MetadataRegistry
{
private array $metadata = [];
public function __construct(
private readonly MetadataExtractor $extractor
) {}
public function getMetadata(string $entityClass): EntityMetadata
{
if (!isset($this->metadata[$entityClass])) {
$this->metadata[$entityClass] = $this->extractor->extractMetadata($entityClass);
}
return $this->metadata[$entityClass];
}
public function hasMetadata(string $entityClass): bool
{
return isset($this->metadata[$entityClass]);
}
public function clearCache(): void
{
$this->metadata = [];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Framework\Database\Metadata;
final readonly class PropertyMetadata
{
public function __construct(
public string $name,
public string $columnName,
public string $type,
public bool $nullable,
public bool $hasDefault,
public mixed $defaultValue,
public array $allTypes = [],
public bool $primary = false,
public bool $autoIncrement = false,
// Eigenschaften für Relationen
public bool $isRelation = false,
public ?string $relationTargetClass = null,
public ?string $relationForeignKey = null,
public ?string $relationLocalKey = null,
public string $relationType = 'hasMany'
) {}
public function isUnionType(): bool
{
return count($this->allTypes) > 1;
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Middleware;
use App\Framework\Database\Cache\CacheStrategy;
use App\Framework\Database\Cache\QueryCacheKey;
use App\Framework\Database\Exception\DatabaseException;
final readonly class CacheMiddleware implements QueryMiddleware
{
public function __construct(
private CacheStrategy $cacheStrategy,
private int $defaultTtlSeconds = 300, // 5 Minuten
private bool $enabled = true,
private array $cacheableOperations = ['query', 'queryOne', 'queryColumn', 'queryScalar']
) {}
public function process(QueryContext $context, callable $next): mixed
{
if (!$this->enabled || !$this->isCacheable($context)) {
return $next($context);
}
$cacheKey = $this->generateCacheKey($context);
// Versuche aus Cache zu lesen
$cachedResult = $this->cacheStrategy->get($cacheKey);
if ($cachedResult !== null) {
// Cache Hit - speichere Statistik
$context = $context->withMetadata('cache_hit', true);
$context = $context->withMetadata('cache_key', $cacheKey->toString());
return $this->deserializeResult($cachedResult, $context->operation);
}
// Cache Miss - führe Query aus
$result = $next($context);
// Speichere Ergebnis im Cache
$ttl = $this->determineTtl($context);
$serializedResult = $this->serializeResult($result, $context->operation);
$this->cacheStrategy->set($cacheKey, $serializedResult, $ttl);
// Speichere Cache-Metadaten
$context = $context->withMetadata('cache_hit', false);
$context = $context->withMetadata('cache_key', $cacheKey->toString());
$context = $context->withMetadata('cache_ttl', $ttl);
return $result;
}
public function getPriority(): int
{
return 30; // Niedrige Priorität - nach HealthCheck und Retry
}
private function isCacheable(QueryContext $context): bool
{
// Nur bestimmte Operationen sind cacheable
if (!in_array($context->operation, $this->cacheableOperations, true)) {
return false;
}
// Transaktions-Queries nicht cachen
if ($context->connection->inTransaction()) {
return false;
}
// Prüfe auf non-cacheable SQL-Patterns
$sql = strtoupper(trim($context->sql));
// SELECT-Statements sind normalerweise cacheable
if (!str_starts_with($sql, 'SELECT')) {
return false;
}
// Bestimmte SELECT-Patterns nicht cachen
$nonCacheablePatterns = [
'NOW()',
'CURRENT_TIMESTAMP',
'RAND()',
'RANDOM()',
'UUID()',
'CURRENT_USER',
'CONNECTION_ID()',
];
foreach ($nonCacheablePatterns as $pattern) {
if (str_contains($sql, $pattern)) {
return false;
}
}
return true;
}
private function generateCacheKey(QueryContext $context): QueryCacheKey
{
return new QueryCacheKey(
$context->sql,
$context->parameters,
$context->connection
);
}
private function determineTtl(QueryContext $context): int
{
// Prüfe auf custom TTL in Metadaten
if ($context->hasMetadata('cache_ttl')) {
return (int) $context->getMetadata('cache_ttl');
}
// Intelligente TTL basierend auf Query-Pattern
$sql = strtoupper(trim($context->sql));
// Lange TTL für statische/referenz Daten
if (str_contains($sql, 'INFORMATION_SCHEMA') ||
str_contains($sql, 'SHOW TABLES') ||
str_contains($sql, 'DESCRIBE ')) {
return 3600; // 1 Stunde
}
// Mittlere TTL für Aggregationen
if (str_contains($sql, 'COUNT(') ||
str_contains($sql, 'SUM(') ||
str_contains($sql, 'AVG(') ||
str_contains($sql, 'GROUP BY')) {
return 900; // 15 Minuten
}
// Standard TTL
return $this->defaultTtlSeconds;
}
private function serializeResult(mixed $result, string $operation): array
{
return [
'operation' => $operation,
'data' => $result,
'timestamp' => time(),
'serialized_at' => microtime(true),
];
}
private function deserializeResult(array $cachedData, string $operation): mixed
{
// Validiere dass Operation übereinstimmt
if ($cachedData['operation'] !== $operation) {
throw new DatabaseException(
"Cache operation mismatch: expected {$operation}, got {$cachedData['operation']}"
);
}
return $cachedData['data'];
}
public function invalidatePattern(string $pattern): int
{
return $this->cacheStrategy->invalidatePattern($pattern);
}
public function invalidateAll(): void
{
$this->cacheStrategy->clear();
}
public function getCacheStats(): array
{
return $this->cacheStrategy->getStats();
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Middleware;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\HealthCheck\ConnectionHealthChecker;
use App\Framework\Database\HealthCheck\HealthCheckResult;
final readonly class HealthCheckMiddleware implements QueryMiddleware
{
private ConnectionHealthChecker $healthChecker;
public function __construct(
private int $healthCheckInterval = 30, // Sekunden
private bool $enabled = true
) {
$this->healthChecker = new ConnectionHealthChecker();
}
public function process(QueryContext $context, callable $next): mixed
{
if (!$this->enabled) {
return $next($context);
}
// Prüfe ob Health Check notwendig ist
$lastHealthCheck = $context->getMetadata('last_health_check', 0);
$currentTime = time();
if (($currentTime - $lastHealthCheck) >= $this->healthCheckInterval) {
$this->performHealthCheck($context);
$context = $context->withMetadata('last_health_check', $currentTime);
}
return $next($context);
}
public function getPriority(): int
{
return 80; // Hohe Priorität - vor Retry aber nach Lazy Loading
}
private function performHealthCheck(QueryContext $context): void
{
$result = $this->healthChecker->checkHealth($context->connection);
if (!$result->isHealthy) {
throw new DatabaseException(
"Health check failed: {$result->message}",
0,
$result->exception
);
}
// Speichere Health Check Ergebnis im Kontext
$context->metadata['health_check_result'] = $result;
}
public function getLastHealthCheckResult(QueryContext $context): ?HealthCheckResult
{
return $context->getMetadata('health_check_result');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Middleware;
final class MiddlewarePipeline
{
/** @var QueryMiddleware[] */
private array $middleware = [];
public function add(QueryMiddleware $middleware): self
{
$this->middleware[] = $middleware;
return $this;
}
public function addMultiple(array $middleware): self
{
foreach ($middleware as $m) {
$this->add($m);
}
return $this;
}
public function process(QueryContext $context, callable $finalHandler): mixed
{
// Sortiere Middleware nach Priorität (höchste zuerst)
$sortedMiddleware = $this->middleware;
usort($sortedMiddleware, fn($a, $b) => $b->getPriority() <=> $a->getPriority());
// Baue die Pipeline von hinten nach vorne auf
$pipeline = $finalHandler;
for ($i = count($sortedMiddleware) - 1; $i >= 0; $i--) {
$middleware = $sortedMiddleware[$i];
$pipeline = fn(QueryContext $ctx) => $middleware->process($ctx, $pipeline);
}
return $pipeline($context);
}
public function getMiddleware(): array
{
return $this->middleware;
}
public function hasMiddleware(string $className): bool
{
foreach ($this->middleware as $middleware) {
if ($middleware instanceof $className) {
return true;
}
}
return false;
}
public function getMiddlewareByType(string $className): ?QueryMiddleware
{
foreach ($this->middleware as $middleware) {
if ($middleware instanceof $className) {
return $middleware;
}
}
return null;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Middleware;
use App\Framework\Database\ConnectionInterface;
final class QueryContext
{
public function __construct(
public readonly string $operation,
public readonly string $sql,
public readonly array $parameters,
public readonly ConnectionInterface $connection,
public array $metadata = []
) {}
public function withMetadata(string $key, mixed $value): self
{
$new = clone $this;
$new->metadata[$key] = $value;
return $new;
}
public function getMetadata(string $key, mixed $default = null): mixed
{
return $this->metadata[$key] ?? $default;
}
public function hasMetadata(string $key): bool
{
return array_key_exists($key, $this->metadata);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Middleware;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\ResultInterface;
interface QueryMiddleware
{
/**
* Verarbeitet eine Query-Operation durch die Middleware
*/
public function process(QueryContext $context, callable $next): mixed;
/**
* Priorität der Middleware (höhere Zahl = früher in der Pipeline)
*/
public function getPriority(): int;
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Middleware;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\HealthCheck\ConnectionHealthChecker;
final readonly class RetryMiddleware implements QueryMiddleware
{
private ConnectionHealthChecker $healthChecker;
public function __construct(
private int $maxRetries = 3,
private int $retryDelayMs = 100,
private array $retryableExceptions = [
\PDOException::class,
DatabaseException::class,
]
) {
$this->healthChecker = new ConnectionHealthChecker();
}
public function process(QueryContext $context, callable $next): mixed
{
// Transaktions-Commits und Rollbacks nicht wiederholen
if (in_array($context->operation, ['commit', 'rollback'], true)) {
return $next($context);
}
return $this->retryOperation($context, $next);
}
public function getPriority(): int
{
return 50; // Mittlere Priorität
}
private function retryOperation(QueryContext $context, callable $next): mixed
{
$attempt = 0;
$lastException = null;
while ($attempt <= $this->maxRetries) {
try {
return $next($context);
} catch (\Throwable $e) {
$lastException = $e;
// Prüfe ob Exception retry-bar ist
if (!$this->isRetryableException($e)) {
throw $e;
}
// Prüfe ob wir noch Versuche haben
if ($attempt >= $this->maxRetries) {
break;
}
$attempt++;
// Health Check vor Retry
if (!$this->healthChecker->checkConnectionAlive($context->connection)) {
// Für LazyGhost: Connection ist automatisch lazy und wird bei nächstem Zugriff neu initialisiert
// Keine explizite Aktion nötig - LazyGhost handled das automatisch
}
// Exponential Backoff
$delay = $this->retryDelayMs * (2 ** ($attempt - 1));
usleep($delay * 1000); // usleep benötigt Mikrosekunden
}
}
throw new DatabaseException(
"Operation failed after {$this->maxRetries} retries. Last error: {$lastException->getMessage()}",
0,
$lastException
);
}
private function isRetryableException(\Throwable $exception): bool
{
foreach ($this->retryableExceptions as $retryableClass) {
if ($exception instanceof $retryableClass) {
// Prüfe spezifische Fehlercodes die nicht retry-bar sind
if ($exception instanceof \PDOException) {
return $this->isPdoExceptionRetryable($exception);
}
return true;
}
}
return false;
}
private function isPdoExceptionRetryable(\PDOException $exception): bool
{
// Bestimmte PDO-Fehler sind nicht retry-bar (z.B. Syntax-Fehler)
$nonRetryableCodes = [
'42000', // Syntax Error
'42S02', // Table doesn't exist
'42S22', // Column doesn't exist
'23000', // Integrity constraint violation
];
$sqlState = $exception->getCode();
return !in_array($sqlState, $nonRetryableCodes, true);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Middleware\MiddlewarePipeline;
use App\Framework\Database\Middleware\QueryContext;
final class MiddlewareConnection implements ConnectionInterface
{
public function __construct(
private ConnectionInterface $baseConnection,
private MiddlewarePipeline $pipeline
) {}
public function execute(string $sql, array $parameters = []): int
{
$context = new QueryContext('execute', $sql, $parameters, $this->baseConnection);
return $this->pipeline->process($context, function(QueryContext $ctx): int {
return $ctx->connection->execute($ctx->sql, $ctx->parameters);
});
}
public function query(string $sql, array $parameters = []): ResultInterface
{
$context = new QueryContext('query', $sql, $parameters, $this->baseConnection);
return $this->pipeline->process($context, function(QueryContext $ctx): ResultInterface {
return $ctx->connection->query($ctx->sql, $ctx->parameters);
});
}
public function queryOne(string $sql, array $parameters = []): ?array
{
$context = new QueryContext('queryOne', $sql, $parameters, $this->baseConnection);
return $this->pipeline->process($context, function(QueryContext $ctx): ?array {
return $ctx->connection->queryOne($ctx->sql, $ctx->parameters);
});
}
public function queryColumn(string $sql, array $parameters = []): array
{
$context = new QueryContext('queryColumn', $sql, $parameters, $this->baseConnection);
return $this->pipeline->process($context, function(QueryContext $ctx): array {
return $ctx->connection->queryColumn($ctx->sql, $ctx->parameters);
});
}
public function queryScalar(string $sql, array $parameters = []): mixed
{
$context = new QueryContext('queryScalar', $sql, $parameters, $this->baseConnection);
return $this->pipeline->process($context, function(QueryContext $ctx): mixed {
return $ctx->connection->queryScalar($ctx->sql, $ctx->parameters);
});
}
public function beginTransaction(): void
{
$context = new QueryContext('beginTransaction', '', [], $this->baseConnection);
$this->pipeline->process($context, function(QueryContext $ctx): void {
$ctx->connection->beginTransaction();
});
}
public function commit(): void
{
$context = new QueryContext('commit', '', [], $this->baseConnection);
$this->pipeline->process($context, function(QueryContext $ctx): void {
$ctx->connection->commit();
});
}
public function rollback(): void
{
$context = new QueryContext('rollback', '', [], $this->baseConnection);
$this->pipeline->process($context, function(QueryContext $ctx): void {
$ctx->connection->rollback();
});
}
public function inTransaction(): bool
{
return $this->baseConnection->inTransaction();
}
public function lastInsertId(): string
{
return $this->baseConnection->lastInsertId();
}
public function getPdo(): \PDO
{
return $this->baseConnection->getPdo();
}
public function getPipeline(): MiddlewarePipeline
{
return $this->pipeline;
}
public function getBaseConnection(): ConnectionInterface
{
return $this->baseConnection;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Core\PathProvider;
use App\Framework\Database\DatabaseManager;
final readonly class ApplyMigrations
{
public function __construct(
private PathProvider $pathProvider,
private DatabaseManager $db
) {}
#[ConsoleCommand('db:migrate', 'Apply all migrations')]
public function __invoke(): void
{
$path = $this->pathProvider->resolvePath('src/Domain/Media/Migrations');
$this->db->migrate($path);
}
#[ConsoleCommand('db:rollback', 'Apply a single migration')]
public function up(): void
{
$path = $this->pathProvider->resolvePath('src/Domain/Media/Migrations');
$this->db->rollback($path);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Database\ConnectionInterface;
interface Migration
{
public function up(ConnectionInterface $connection): void;
public function down(ConnectionInterface $connection): void;
public function getVersion(): string;
public function getDescription(): string;
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Migration;
use App\Framework\Database\Exception\DatabaseException;
final class MigrationLoader
{
private string $migrationsPath;
public function __construct(string $migrationsPath)
{
$this->migrationsPath = $migrationsPath;
}
/**
* @return Migration[]
*/
public function loadMigrations(): array
{
if (!is_dir($this->migrationsPath)) {
throw new DatabaseException("Migrations directory does not exist: {$this->migrationsPath}");
}
$migrations = [];
$files = glob($this->migrationsPath . '/*.php');
foreach ($files as $file) {
$className = $this->getClassNameFromFile($file);
if (!$className) {
continue;
}
require_once $file;
if (!class_exists($className)) {
continue;
}
$reflection = new \ReflectionClass($className);
if (!$reflection->implementsInterface(Migration::class)) {
continue;
}
$migration = $reflection->newInstance();
$migrations[] = $migration;
}
usort($migrations, fn($a, $b) => $a->getVersion() <=> $b->getVersion());
return $migrations;
}
private function getClassNameFromFile(string $file): ?string
{
$content = file_get_contents($file);
$namespace = '';
if (preg_match('/namespace\s+([^;]+);/i', $content, $matches)) {
$namespace = $matches[1] . '\\';
}
if (preg_match('/class\s+([^\s{]+)/i', $content, $matches)) {
return $namespace . $matches[1];
}
return null;
}
}

View File

@@ -0,0 +1,145 @@
<?php
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;
final class MigrationRunner
{
private ConnectionInterface $connection;
private string $migrationsTable;
public function __construct(ConnectionInterface $connection, string $migrationsTable = 'migrations')
{
$this->connection = $connection;
$this->migrationsTable = $migrationsTable;
$this->ensureMigrationsTable();
}
/**
* @param Migration[] $migrations
*/
public function migrate(array $migrations): array
{
$executedMigrations = [];
$appliedVersions = $this->getAppliedVersions();
foreach ($migrations as $migration) {
if (in_array($migration->getVersion(), $appliedVersions, true)) {
continue;
}
try {
Transaction::run($this->connection, function() use ($migration) {
echo "Migrating: {$migration->getVersion()} - {$migration->getDescription()}\n";
$migration->up($this->connection);
$this->connection->execute(
"INSERT INTO {$this->migrationsTable} (version, description, executed_at) VALUES (?, ?, ?)",
[$migration->getVersion(), $migration->getDescription(), date('Y-m-d H:i:s')]
);
});
$executedMigrations[] = $migration->getVersion();
echo "Migrated: {$migration->getVersion()}\n";
} catch (\Throwable $e) {
throw new DatabaseException(
"Migration {$migration->getVersion()} failed: {$e->getMessage()}",
0,
$e
);
}
}
return $executedMigrations;
}
/**
* @param Migration[] $migrations
*/
public function rollback(array $migrations, int $steps = 1): array
{
$rolledBackMigrations = [];
$appliedVersions = $this->getAppliedVersions();
$sortedMigrations = $migrations;
usort($sortedMigrations, fn($a, $b) => $b->getVersion() <=> $a->getVersion());
$count = 0;
foreach ($sortedMigrations as $migration) {
if ($count >= $steps) {
break;
}
if (!in_array($migration->getVersion(), $appliedVersions, true)) {
continue;
}
try {
Transaction::run($this->connection, function() use ($migration) {
echo "Rolling back: {$migration->getVersion()} - {$migration->getDescription()}\n";
$migration->down($this->connection);
$this->connection->execute(
"DELETE FROM {$this->migrationsTable} WHERE version = ?",
[$migration->getVersion()]
);
});
$rolledBackMigrations[] = $migration->getVersion();
$count++;
echo "Rolled back: {$migration->getVersion()}\n";
} catch (\Throwable $e) {
throw new DatabaseException(
"Rollback {$migration->getVersion()} failed: {$e->getMessage()}",
0,
$e
);
}
}
return $rolledBackMigrations;
}
public function getStatus(array $migrations): array
{
$appliedVersions = $this->getAppliedVersions();
$status = [];
foreach ($migrations as $migration) {
$status[] = [
'version' => $migration->getVersion(),
'description' => $migration->getDescription(),
'applied' => in_array($migration->getVersion(), $appliedVersions, true),
];
}
return $status;
}
private function getAppliedVersions(): array
{
return $this->connection->queryColumn(
"SELECT version FROM {$this->migrationsTable} ORDER BY executed_at"
);
}
private function ensureMigrationsTable(): void
{
$sql = "CREATE TABLE IF NOT EXISTS {$this->migrationsTable} (
id INT PRIMARY KEY AUTO_INCREMENT,
version VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
executed_at DATETIME NOT NULL,
INDEX idx_version (version)
)";
$this->connection->execute($sql);
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Exception\DatabaseException;
final class PdoConnection implements ConnectionInterface
{
private \PDO $pdo;
public function __construct(\PDO $pdo)
{
$this->pdo = $pdo;
# SOllte bereits aus den Options kommen!
#$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
#$this->pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
}
public function execute(string $sql, array $parameters = []): int
{
try {
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
return $statement->rowCount();
} catch (\PDOException $e) {
throw new DatabaseException("Failed to execute query: {$e->getMessage()}", 0, $e);
}
}
public function query(string $sql, array $parameters = []): ResultInterface
{
try {
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
return new PdoResult($statement);
} catch (\PDOException $e) {
throw new DatabaseException("Failed to execute query: {$e->getMessage()} --- SQL: {$sql} PARAMETERS: {".implode(", ", $parameters)."}", 0, $e);
}
}
public function queryOne(string $sql, array $parameters = []): ?array
{
$result = $this->query($sql, $parameters);
return $result->fetch();
}
public function queryColumn(string $sql, array $parameters = []): array
{
$result = $this->query($sql, $parameters);
return $result->fetchColumn();
}
public function queryScalar(string $sql, array $parameters = []): mixed
{
$result = $this->query($sql, $parameters);
return $result->fetchScalar();
}
public function beginTransaction(): void
{
try {
$this->pdo->beginTransaction();
} catch (\PDOException $e) {
throw new DatabaseException("Failed to begin transaction: {$e->getMessage()}", 0, $e);
}
}
public function commit(): void
{
try {
$this->pdo->commit();
} catch (\PDOException $e) {
throw new DatabaseException("Failed to commit transaction: {$e->getMessage()}", 0, $e);
}
}
public function rollback(): void
{
try {
$this->pdo->rollBack();
} catch (\PDOException $e) {
throw new DatabaseException("Failed to rollback transaction: {$e->getMessage()}", 0, $e);
}
}
public function inTransaction(): bool
{
return $this->pdo->inTransaction();
}
public function lastInsertId(): string
{
return $this->pdo->lastInsertId();
}
public function getPdo(): \PDO
{
return $this->pdo;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\ResultInterface;
final class PdoResult implements ResultInterface
{
private \PDOStatement $statement;
private ?array $rows = null;
public function __construct(\PDOStatement $statement)
{
$this->statement = $statement;
}
public function fetch(): ?array
{
$row = $this->statement->fetch();
return $row === false ? null : $row;
}
public function fetchAll(): array
{
if ($this->rows === null) {
$this->rows = $this->statement->fetchAll();
}
return $this->rows;
}
public function fetchColumn(int $column = 0): array
{
return $this->statement->fetchAll(\PDO::FETCH_COLUMN, $column);
}
public function fetchScalar(): mixed
{
$value = $this->statement->fetchColumn();
return $value === false ? null : $value;
}
public function rowCount(): int
{
return $this->statement->rowCount();
}
public function count(): int
{
return count($this->fetchAll());
}
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->fetchAll());
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Framework\Database;
/**
* Represents a connection wrapper in a connection pool.
*
* The PooledConnection class acts as a proxy for an actual database connection, managing the lifecycle
* of the underlying connection and facilitating its return to the connection pool upon release or destruction.
* It delegates operations to the underlying `ConnectionInterface` implementation.
*/
final class PooledConnection implements ConnectionInterface
{
private ConnectionInterface $connection;
private ConnectionPool $pool;
private string $id;
private bool $released = false;
public function __construct(ConnectionInterface $connection, ConnectionPool $pool, string $id)
{
$this->connection = $connection;
$this->pool = $pool;
$this->id = $id;
}
public function execute(string $sql, array $parameters = []): int
{
return $this->connection->execute($sql, $parameters);
}
public function query(string $sql, array $parameters = []): ResultInterface
{
return $this->connection->query($sql, $parameters);
}
public function queryOne(string $sql, array $parameters = []): ?array
{
return $this->connection->queryOne($sql, $parameters);
}
public function queryColumn(string $sql, array $parameters = []): array
{
return $this->connection->queryColumn($sql, $parameters);
}
public function queryScalar(string $sql, array $parameters = []): mixed
{
return $this->connection->queryScalar($sql, $parameters);
}
public function beginTransaction(): void
{
$this->connection->beginTransaction();
}
public function commit(): void
{
$this->connection->commit();
}
public function rollback(): void
{
$this->connection->rollback();
}
public function inTransaction(): bool
{
return $this->connection->inTransaction();
}
public function lastInsertId(): string
{
return $this->connection->lastInsertId();
}
public function getPdo(): \PDO
{
return $this->connection->getPdo();
}
public function release(): void
{
if (!$this->released) {
$this->pool->releaseConnection($this->id);
$this->released = true;
}
}
public function __destruct()
{
$this->release();
}
}

View File

@@ -0,0 +1,21 @@
# Database Framework
Ein hochperformantes, erweiterbares Database-Framework für PHP 8.4+ mit Support für Lazy Loading, Middleware-Pipeline, Caching, Health Checks und Retry-Logic.
## Inhaltsverzeichnis
- [Schnellstart](#schnellstart)
- [Features](#features)
- [Connection Types](#connection-types)
- [Middleware System](#middleware-system)
- [Lazy Loading](#lazy-loading)
- [Query Caching](#query-caching)
- [Health Checks](#health-checks)
- [Retry Logic](#retry-logic)
- [Konfiguration](#konfiguration)
- [Best Practices](#best-practices)
- [Performance](#performance)
## Schnellstart
### Einfache Connection

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Exception\DatabaseException;
/**
* Class ReadWriteConnection provides a mechanism to handle separate writing and reading database connections.
* It ensures that write-heavy operations are directed to a designated write connection,
* while read operations are distributed among a pool of read connections.
*/
final class ReadWriteConnection implements ConnectionInterface
{
private ConnectionInterface $writeConnection;
private array $readConnections;
private int $currentReadIndex = 0;
private bool $forceWrite = false;
public function __construct(ConnectionInterface $writeConnection, array $readConnections)
{
$this->writeConnection = $writeConnection;
$this->readConnections = $readConnections;
if (empty($this->readConnections)) {
throw new DatabaseException('At least one read connection is required');
}
}
public function execute(string $sql, array $parameters = []): int
{
$this->forceWrite = true;
return $this->writeConnection->execute($sql, $parameters);
}
public function query(string $sql, array $parameters = []): ResultInterface
{
if ($this->shouldUseWriteConnection($sql)) {
return $this->writeConnection->query($sql, $parameters);
}
return $this->getReadConnection()->query($sql, $parameters);
}
public function queryOne(string $sql, array $parameters = []): ?array
{
if ($this->shouldUseWriteConnection($sql)) {
return $this->writeConnection->queryOne($sql, $parameters);
}
return $this->getReadConnection()->queryOne($sql, $parameters);
}
public function queryColumn(string $sql, array $parameters = []): array
{
if ($this->shouldUseWriteConnection($sql)) {
return $this->writeConnection->queryColumn($sql, $parameters);
}
return $this->getReadConnection()->queryColumn($sql, $parameters);
}
public function queryScalar(string $sql, array $parameters = []): mixed
{
if ($this->shouldUseWriteConnection($sql)) {
return $this->writeConnection->queryScalar($sql, $parameters);
}
return $this->getReadConnection()->queryScalar($sql, $parameters);
}
public function beginTransaction(): void
{
$this->forceWrite = true;
$this->writeConnection->beginTransaction();
}
public function commit(): void
{
$this->writeConnection->commit();
$this->forceWrite = false;
}
public function rollback(): void
{
$this->writeConnection->rollback();
$this->forceWrite = false;
}
public function inTransaction(): bool
{
return $this->writeConnection->inTransaction();
}
public function lastInsertId(): string
{
return $this->writeConnection->lastInsertId();
}
public function getPdo(): \PDO
{
return $this->writeConnection->getPdo();
}
public function forceWriteConnection(): void
{
$this->forceWrite = true;
}
public function resetConnectionMode(): void
{
$this->forceWrite = false;
}
private function shouldUseWriteConnection(string $sql): bool
{
if ($this->forceWrite || $this->inTransaction()) {
return true;
}
$sql = trim(strtoupper($sql));
$writeOperations = ['INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE'];
return array_any($writeOperations, fn($operation) => str_starts_with($sql, $operation));
}
private function getReadConnection(): ConnectionInterface
{
$connection = $this->readConnections[$this->currentReadIndex];
$this->currentReadIndex = ($this->currentReadIndex + 1) % count($this->readConnections);
return $connection;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Repository;
use App\Framework\Database\EntityManager;
/**
* Basis-Repository für Entities
*/
abstract class EntityRepository
{
protected string $entityClass;
public function __construct(
protected EntityManager $entityManager
) {
if (!isset($this->entityClass)) {
throw new \LogicException(static::class . " must define \$entityClass property");
}
}
/**
* Findet Entity nach ID
*/
public function find(string $id): ?object
{
return $this->entityManager->find($this->entityClass, $id);
}
/**
* Findet Entity nach ID (eager loading)
*/
public function findEager(string $id): ?object
{
return $this->entityManager->findEager($this->entityClass, $id);
}
/**
* Findet alle Entities
*/
public function findAll(): array
{
return $this->entityManager->findAll($this->entityClass);
}
/**
* Findet alle Entities (eager loading)
*/
public function findAllEager(): array
{
return $this->entityManager->findAllEager($this->entityClass);
}
/**
* Findet Entities nach Kriterien
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
return $this->entityManager->findBy($this->entityClass, $criteria, $orderBy, $limit);
}
/**
* Findet eine Entity nach Kriterien
*/
public function findOneBy(array $criteria): ?object
{
return $this->entityManager->findOneBy($this->entityClass, $criteria);
}
/**
* Speichert eine Entity
*/
public function save(object $entity): object
{
return $this->entityManager->save($entity);
}
/**
* Speichert mehrere Entities
*/
public function saveAll(array $entities): array
{
return $this->entityManager->saveAll(...$entities);
}
/**
* Löscht eine Entity
*/
public function delete(object $entity): void
{
$this->entityManager->delete($entity);
}
/**
* Führt eine Funktion in einer Transaktion aus
*/
public function transaction(callable $callback): mixed
{
return $this->entityManager->transaction($callback);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Framework\Database;
interface ResultInterface extends \IteratorAggregate, \Countable
{
public function fetch(): ?array;
public function fetchAll(): array;
public function fetchColumn(int $column = 0): array;
public function fetchScalar(): mixed;
public function rowCount(): int;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
final class Transaction
{
public static function run(ConnectionInterface $connection, callable $callback): mixed
{
// Prüfe ob bereits eine Transaktion läuft
if ($connection->inTransaction()) {
// Keine neue Transaktion starten, direkt Callback ausführen
return $callback();
}
$connection->beginTransaction();
try {
$result = $callback($connection);
$connection->commit();
return $result;
} catch (\Throwable $e) {
$connection->rollback();
throw $e;
}
}
}

View File

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

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
final class JsonCaster implements TypeCasterInterface
{
public function fromDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
throw new \InvalidArgumentException('Invalid JSON: ' . json_last_error_msg());
}
return $value;
}
public function toDatabase(mixed $value, array $options = []): mixed
{
if ($value === null) {
return null;
}
return json_encode($value, JSON_THROW_ON_ERROR);
}
public function supports(string $type): bool
{
return $type === 'array' || $type === 'object';
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
interface TypeCasterInterface
{
/**
* Konvertiert einen Datenbank-Wert zu einem Value Object
*/
public function fromDatabase(mixed $value, array $options = []): mixed;
/**
* Konvertiert ein Value Object zu einem Datenbank-Wert
*/
public function toDatabase(mixed $value, array $options = []): mixed;
/**
* Prüft ob der Caster für den gegebenen Typ zuständig ist
*/
public function supports(string $type): bool;
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\TypeCaster;
use App\Framework\Database\Exception\DatabaseException;
final class TypeCasterRegistry
{
/** @var array<string, TypeCasterInterface> */
private array $casters = [];
/** @var array<string, TypeCasterInterface> Mapping von Typ zu Caster */
private array $typeToCaster = [];
private bool $discovered = false;
public function __construct()
{
$this->discoverCasters();
}
public function register(string $casterClass, TypeCasterInterface $caster): void
{
$this->casters[$casterClass] = $caster;
$this->buildTypeMapping();
}
public function get(string $casterClass): TypeCasterInterface
{
if (!isset($this->casters[$casterClass])) {
// Automatisches Laden wenn Caster noch nicht registriert
if (class_exists($casterClass) && is_subclass_of($casterClass, TypeCasterInterface::class)) {
$this->casters[$casterClass] = new $casterClass();
$this->buildTypeMapping();
} else {
throw new DatabaseException("Type caster {$casterClass} not found or invalid");
}
}
return $this->casters[$casterClass];
}
public function has(string $casterClass): bool
{
return isset($this->casters[$casterClass]) ||
(class_exists($casterClass) && is_subclass_of($casterClass, TypeCasterInterface::class));
}
/**
* Findet automatisch den passenden Caster für einen Typ
*/
public function getCasterForType(string $type): ?TypeCasterInterface
{
if (!$this->discovered) {
$this->discoverCasters();
}
return $this->typeToCaster[$type] ?? null;
}
/**
* Entdeckt automatisch alle verfügbaren TypeCaster
*/
private function discoverCasters(): void
{
$casterNamespace = 'App\\Framework\\Database\\TypeCaster\\';
$casterPath = __DIR__;
// Alle PHP-Dateien im TypeCaster-Verzeichnis scannen
$files = glob($casterPath . '/*.php');
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = $casterNamespace . $className;
// Nur echte Caster-Klassen laden, nicht das Interface
if ($className !== 'TypeCasterInterface' &&
class_exists($fullClassName) &&
is_subclass_of($fullClassName, TypeCasterInterface::class) &&
!new \ReflectionClass($fullClassName)->isAbstract()) {
try {
$this->casters[$fullClassName] = new $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 (class_exists($fullClassName)) {
$types[] = $fullClassName;
}
}
}
return $types;
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\Metadata\PropertyMetadata;
use App\Framework\Database\TypeCaster\TypeCasterRegistry;
final readonly class TypeConverter
{
public function __construct(
private ?TypeCasterRegistry $casterRegistry = null
) {}
public function convertValue(mixed $value, PropertyMetadata $property): mixed
{
if($property->isRelation) {
return $value;
}
// Null-Handling
if ($value === null) {
if ($property->nullable) {
return null;
}
if ($property->hasDefault) {
return $property->defaultValue;
}
throw new DatabaseException("Value cannot be null for non-nullable property {$property->name}");
}
// Automatische Type Caster-Erkennung
if ($this->casterRegistry) {
$caster = $this->casterRegistry->getCasterForType($property->type);
if ($caster) {
return $caster->fromDatabase($value);
}
}
// Union Types: Versuche jeden Typ
if ($property->isUnionType()) {
foreach ($property->allTypes as $type) {
try {
// Zuerst Caster für Union-Typ versuchen
if ($this->casterRegistry) {
$caster = $this->casterRegistry->getCasterForType($type);
if ($caster) {
return $caster->fromDatabase($value);
}
}
// Fallback auf primitive Konvertierung
return $this->convertToType($value, $type);
} catch (\Exception) {
continue;
}
}
}
return $this->convertToType($value, $property->type);
}
/**
* Konvertiert Value Objects zurück zu Datenbank-Werten
*/
public function convertToDatabase(mixed $value, PropertyMetadata $property): mixed
{
if ($value === null) {
return null;
}
// Automatische Type Caster-Erkennung basierend auf Objekt-Typ
if ($this->casterRegistry && is_object($value)) {
$objectType = get_class($value);
$caster = $this->casterRegistry->getCasterForType($objectType);
if ($caster) {
return $caster->toDatabase($value);
}
}
// Standard-Konvertierung für primitive Typen
return $value;
}
private function convertToType(mixed $value, string $type): mixed
{
return match($type) {
'int' => (int) $value,
'float' => (float) $value,
'bool' => $this->convertToBool($value),
'string' => (string) $value,
'array' => is_string($value) ? json_decode($value, true) : (array) $value,
default => $value
};
}
private function convertToBool(mixed $value): bool
{
if (is_bool($value)) return $value;
if (is_numeric($value)) return (bool) $value;
if (is_string($value)) {
return match(strtolower(trim($value))) {
'true', '1', 'yes', 'on' => true,
'false', '0', 'no', 'off', '' => false,
default => (bool) $value
};
}
return (bool) $value;
}
}