chore: complete update
This commit is contained in:
17
src/Framework/Database/Attributes/Column.php
Normal file
17
src/Framework/Database/Attributes/Column.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
14
src/Framework/Database/Attributes/Entity.php
Normal file
14
src/Framework/Database/Attributes/Entity.php
Normal 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'
|
||||
) {}
|
||||
}
|
||||
18
src/Framework/Database/Attributes/Type.php
Normal file
18
src/Framework/Database/Attributes/Type.php
Normal 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'
|
||||
) {}
|
||||
}
|
||||
208
src/Framework/Database/Cache/CacheAdapterStrategy.php
Normal file
208
src/Framework/Database/Cache/CacheAdapterStrategy.php
Normal 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}*");
|
||||
}
|
||||
}
|
||||
43
src/Framework/Database/Cache/CacheStrategy.php
Normal file
43
src/Framework/Database/Cache/CacheStrategy.php
Normal 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;
|
||||
}
|
||||
148
src/Framework/Database/Cache/QueryCacheKey.php
Normal file
148
src/Framework/Database/Cache/QueryCacheKey.php
Normal 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;
|
||||
}
|
||||
}
|
||||
138
src/Framework/Database/Cache/SimpleCacheStrategy.php
Normal file
138
src/Framework/Database/Cache/SimpleCacheStrategy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
14
src/Framework/Database/Config/DatabaseConfig.php
Normal file
14
src/Framework/Database/Config/DatabaseConfig.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
13
src/Framework/Database/Config/PoolConfig.php
Normal file
13
src/Framework/Database/Config/PoolConfig.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
29
src/Framework/Database/ConnectionInterface.php
Normal file
29
src/Framework/Database/ConnectionInterface.php
Normal 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;
|
||||
}
|
||||
86
src/Framework/Database/ConnectionPool.php
Normal file
86
src/Framework/Database/ConnectionPool.php
Normal 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;
|
||||
}
|
||||
}
|
||||
332
src/Framework/Database/DatabaseFactory.php
Normal file
332
src/Framework/Database/DatabaseFactory.php
Normal 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
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
105
src/Framework/Database/DatabaseManager.php
Normal file
105
src/Framework/Database/DatabaseManager.php
Normal 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() ?? [];
|
||||
}
|
||||
}
|
||||
16
src/Framework/Database/Driver/Driver.php
Normal file
16
src/Framework/Database/Driver/Driver.php
Normal 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
|
||||
);
|
||||
}
|
||||
29
src/Framework/Database/Driver/DriverConfig.php
Normal file
29
src/Framework/Database/Driver/DriverConfig.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/Framework/Database/Driver/DriverType.php
Normal file
12
src/Framework/Database/Driver/DriverType.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Framework\Database\Driver;
|
||||
|
||||
enum DriverType:string
|
||||
{
|
||||
case MYSQL = 'mysql';
|
||||
|
||||
case PGSQL = 'pgsql';
|
||||
|
||||
case SQLITE = 'sqlite';
|
||||
}
|
||||
37
src/Framework/Database/Driver/MysqlDriver.php
Normal file
37
src/Framework/Database/Driver/MysqlDriver.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
38
src/Framework/Database/Driver/PostgresDriver.php
Normal file
38
src/Framework/Database/Driver/PostgresDriver.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
src/Framework/Database/Driver/SqliteDriver.php
Normal file
39
src/Framework/Database/Driver/SqliteDriver.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
469
src/Framework/Database/EntityManager.php
Normal file
469
src/Framework/Database/EntityManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Framework/Database/EntityManagerFactory.php
Normal file
37
src/Framework/Database/EntityManagerFactory.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/Framework/Database/EntityManagerInitializer.php
Normal file
55
src/Framework/Database/EntityManagerInitializer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/Framework/Database/Example/README.md
Normal file
69
src/Framework/Database/Example/README.md
Normal 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);
|
||||
```
|
||||
51
src/Framework/Database/Example/UserEntity.php
Normal file
51
src/Framework/Database/Example/UserEntity.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
src/Framework/Database/Example/UserRepository.php
Normal file
50
src/Framework/Database/Example/UserRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/Framework/Database/Exception/DatabaseException.php
Normal file
9
src/Framework/Database/Exception/DatabaseException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Exception;
|
||||
|
||||
class DatabaseException extends \Exception
|
||||
{
|
||||
}
|
||||
13
src/Framework/Database/Exception/EntityNotFoundException.php
Normal file
13
src/Framework/Database/Exception/EntityNotFoundException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
233
src/Framework/Database/HealthCheck/ConnectionHealthChecker.php
Normal file
233
src/Framework/Database/HealthCheck/ConnectionHealthChecker.php
Normal 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);
|
||||
}
|
||||
}
|
||||
75
src/Framework/Database/HealthCheck/HealthCheckResult.php
Normal file
75
src/Framework/Database/HealthCheck/HealthCheckResult.php
Normal 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);
|
||||
}
|
||||
}
|
||||
162
src/Framework/Database/HealthCheck/HealthCheckScheduler.php
Normal file
162
src/Framework/Database/HealthCheck/HealthCheckScheduler.php
Normal 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;
|
||||
}
|
||||
}
|
||||
156
src/Framework/Database/Hydrator.php
Normal file
156
src/Framework/Database/Hydrator.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
27
src/Framework/Database/IdGenerator.php
Normal file
27
src/Framework/Database/IdGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
59
src/Framework/Database/IdentityMap.php
Normal file
59
src/Framework/Database/IdentityMap.php
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/Framework/Database/LazyConnectionFactory.php
Normal file
90
src/Framework/Database/LazyConnectionFactory.php
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
385
src/Framework/Database/LazyLoader.php
Normal file
385
src/Framework/Database/LazyLoader.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Framework/Database/Metadata/EntityMetadata.php
Normal file
30
src/Framework/Database/Metadata/EntityMetadata.php
Normal 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;
|
||||
}
|
||||
}
|
||||
501
src/Framework/Database/Metadata/MetadataExtractor.php
Normal file
501
src/Framework/Database/Metadata/MetadataExtractor.php
Normal 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");
|
||||
}
|
||||
}
|
||||
33
src/Framework/Database/Metadata/MetadataRegistry.php
Normal file
33
src/Framework/Database/Metadata/MetadataRegistry.php
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
29
src/Framework/Database/Metadata/PropertyMetadata.php
Normal file
29
src/Framework/Database/Metadata/PropertyMetadata.php
Normal 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;
|
||||
}
|
||||
}
|
||||
174
src/Framework/Database/Middleware/CacheMiddleware.php
Normal file
174
src/Framework/Database/Middleware/CacheMiddleware.php
Normal 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();
|
||||
}
|
||||
}
|
||||
65
src/Framework/Database/Middleware/HealthCheckMiddleware.php
Normal file
65
src/Framework/Database/Middleware/HealthCheckMiddleware.php
Normal 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');
|
||||
}
|
||||
}
|
||||
67
src/Framework/Database/Middleware/MiddlewarePipeline.php
Normal file
67
src/Framework/Database/Middleware/MiddlewarePipeline.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
src/Framework/Database/Middleware/QueryContext.php
Normal file
35
src/Framework/Database/Middleware/QueryContext.php
Normal 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);
|
||||
}
|
||||
}
|
||||
21
src/Framework/Database/Middleware/QueryMiddleware.php
Normal file
21
src/Framework/Database/Middleware/QueryMiddleware.php
Normal 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;
|
||||
}
|
||||
109
src/Framework/Database/Middleware/RetryMiddleware.php
Normal file
109
src/Framework/Database/Middleware/RetryMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
113
src/Framework/Database/MiddlewareConnection.php
Normal file
113
src/Framework/Database/MiddlewareConnection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
src/Framework/Database/Migration/ApplyMigrations.php
Normal file
32
src/Framework/Database/Migration/ApplyMigrations.php
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/Framework/Database/Migration/Migration.php
Normal file
18
src/Framework/Database/Migration/Migration.php
Normal 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;
|
||||
}
|
||||
73
src/Framework/Database/Migration/MigrationLoader.php
Normal file
73
src/Framework/Database/Migration/MigrationLoader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
145
src/Framework/Database/Migration/MigrationRunner.php
Normal file
145
src/Framework/Database/Migration/MigrationRunner.php
Normal 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);
|
||||
}
|
||||
}
|
||||
104
src/Framework/Database/PdoConnection.php
Normal file
104
src/Framework/Database/PdoConnection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/Framework/Database/PdoResult.php
Normal file
59
src/Framework/Database/PdoResult.php
Normal 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());
|
||||
}
|
||||
}
|
||||
93
src/Framework/Database/PooledConnection.php
Normal file
93
src/Framework/Database/PooledConnection.php
Normal 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();
|
||||
}
|
||||
}
|
||||
21
src/Framework/Database/README.md
Normal file
21
src/Framework/Database/README.md
Normal 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
|
||||
136
src/Framework/Database/ReadWriteConnection.php
Normal file
136
src/Framework/Database/ReadWriteConnection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
103
src/Framework/Database/Repository/EntityRepository.php
Normal file
103
src/Framework/Database/Repository/EntityRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/Framework/Database/ResultInterface.php
Normal file
16
src/Framework/Database/ResultInterface.php
Normal 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;
|
||||
}
|
||||
28
src/Framework/Database/Transaction.php
Normal file
28
src/Framework/Database/Transaction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/Framework/Database/TypeCaster/EmailCaster.php
Normal file
41
src/Framework/Database/TypeCaster/EmailCaster.php
Normal 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;
|
||||
}
|
||||
}
|
||||
39
src/Framework/Database/TypeCaster/JsonCaster.php
Normal file
39
src/Framework/Database/TypeCaster/JsonCaster.php
Normal 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';
|
||||
}
|
||||
}
|
||||
23
src/Framework/Database/TypeCaster/TypeCasterInterface.php
Normal file
23
src/Framework/Database/TypeCaster/TypeCasterInterface.php
Normal 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;
|
||||
}
|
||||
141
src/Framework/Database/TypeCaster/TypeCasterRegistry.php
Normal file
141
src/Framework/Database/TypeCaster/TypeCasterRegistry.php
Normal 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;
|
||||
}
|
||||
}
|
||||
113
src/Framework/Database/TypeConverter.php
Normal file
113
src/Framework/Database/TypeConverter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user