Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Async\AsyncService;
use App\Framework\Database\Contracts\AsyncCapable;
/**
* Decorator der jede ConnectionInterface-Implementierung um async Property erweitert
*
* Usage: $db->async->queryMultiple([...])
*/
final readonly class AsyncAwareConnection implements ConnectionInterface, AsyncCapable
{
private AsyncDatabaseAdapter $asyncAdapter;
public function __construct(
private ConnectionInterface $connection,
private AsyncService $asyncService
) {
$this->asyncAdapter = new AsyncDatabaseAdapter($this->connection, $this->asyncService);
}
// === STANDARD CONNECTION INTERFACE (Delegation) ===
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();
}
// === ASYNC CAPABILITY ===
/**
* Get async adapter for parallel operations
*/
public function async(): AsyncDatabaseAdapter
{
return $this->asyncAdapter;
}
/**
* Get wrapped connection instance
*/
public function getWrappedConnection(): ConnectionInterface
{
return $this->connection;
}
/**
* Get statistics including async metrics
*/
public function getStats(): array
{
$baseStats = method_exists($this->connection, 'getStatistics') ? $this->connection->getStatistics() : [];
return array_merge($baseStats, [
'async_enabled' => true,
'async_stats' => $this->asyncAdapter->getStats(),
'connection_type' => get_class($this->connection),
'supports_read_replicas' => $this->connection instanceof ReadWriteConnection,
]);
}
}

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Async\AsyncPromise;
use App\Framework\Async\AsyncService;
use App\Framework\Core\ValueObjects\Duration;
/**
* Async Adapter für Database - Fluent API Style
*
* Provides async operations as property access: $db->async->queryMultiple()
*/
final readonly class AsyncDatabaseAdapter
{
public function __construct(
private ConnectionInterface $connection,
private AsyncService $asyncService
) {
}
/**
* Execute query asynchronously
*/
public function query(string $sql, array $parameters = []): AsyncPromise
{
return $this->asyncService->promise(
fn () => $this->connection->query($sql, $parameters)
);
}
/**
* Execute queryOne asynchronously
*/
public function queryOne(string $sql, array $parameters = []): AsyncPromise
{
return $this->asyncService->promise(
fn () => $this->connection->queryOne($sql, $parameters)
);
}
/**
* Execute queryScalar asynchronously
*/
public function queryScalar(string $sql, array $parameters = []): AsyncPromise
{
return $this->asyncService->promise(
fn () => $this->connection->queryScalar($sql, $parameters)
);
}
/**
* Execute queryColumn asynchronously
*/
public function queryColumn(string $sql, array $parameters = []): AsyncPromise
{
return $this->asyncService->promise(
fn () => $this->connection->queryColumn($sql, $parameters)
);
}
/**
* Execute statement asynchronously
*/
public function execute(string $sql, array $parameters = []): AsyncPromise
{
return $this->asyncService->promise(
fn () => $this->connection->execute($sql, $parameters)
);
}
/**
* Execute multiple queries in parallel
*/
public function queryMultiple(array $queries): array
{
$operations = [];
foreach ($queries as $key => $queryData) {
$sql = $queryData['sql'] ?? $queryData;
$params = $queryData['params'] ?? [];
$operations[$key] = fn () => $this->connection->query($sql, $params);
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Parallel data aggregation from multiple tables
*/
public function aggregate(array $namedQueries): array
{
$operations = [];
foreach ($namedQueries as $name => $queryData) {
$operations[$name] = function () use ($queryData) {
try {
$sql = $queryData['sql'] ?? $queryData;
$params = $queryData['params'] ?? [];
$method = $queryData['method'] ?? 'query';
$result = match($method) {
'queryOne' => $this->connection->queryOne($sql, $params),
'queryColumn' => $this->connection->queryColumn($sql, $params),
'queryScalar' => $this->connection->queryScalar($sql, $params),
default => $this->connection->query($sql, $params),
};
return [
'success' => true,
'data' => $result,
'error' => null,
];
} catch (\Exception $e) {
return [
'success' => false,
'data' => null,
'error' => $e->getMessage(),
];
}
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Batch insert operations
*/
public function batchInsert(string $table, array $columns, array $rows, int $batchSize = 100): array
{
if (empty($rows)) {
return [];
}
$batches = array_chunk($rows, $batchSize);
$placeholders = '(' . str_repeat('?,', count($columns) - 1) . '?)';
$operations = [];
foreach ($batches as $i => $batch) {
$operations["batch_$i"] = function () use ($table, $columns, $batch, $placeholders) {
$values = str_repeat($placeholders . ',', count($batch) - 1) . $placeholders;
$sql = "INSERT INTO `$table` (`" . implode('`, `', $columns) . "`) VALUES $values";
$params = [];
foreach ($batch as $row) {
$params = array_merge($params, array_values($row));
}
return $this->connection->execute($sql, $params);
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Batch update operations
*/
public function batchUpdate(string $table, array $updates, string $keyColumn = 'id', int $batchSize = 50): array
{
if (empty($updates)) {
return [];
}
$batches = array_chunk($updates, $batchSize);
$operations = [];
foreach ($batches as $i => $batch) {
$operations["batch_$i"] = function () use ($table, $batch, $keyColumn) {
$affected = 0;
foreach ($batch as $update) {
$keyValue = $update[$keyColumn];
unset($update[$keyColumn]);
$setParts = [];
$params = [];
foreach ($update as $column => $value) {
$setParts[] = "`$column` = ?";
$params[] = $value;
}
$params[] = $keyValue;
$sql = "UPDATE `$table` SET " . implode(', ', $setParts) . " WHERE `$keyColumn` = ?";
$affected += $this->connection->execute($sql, $params);
}
return $affected;
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Parallel read-ahead for related data
*/
public function readAhead(array $queries, ?Duration $timeout = null): array
{
$timeout ??= Duration::fromSeconds(10);
$operations = [];
foreach ($queries as $key => $queryData) {
$operations[$key] = function () use ($queryData, $timeout) {
return $this->asyncService->withTimeout(function () use ($queryData) {
$sql = $queryData['sql'] ?? $queryData;
$params = $queryData['params'] ?? [];
$method = $queryData['method'] ?? 'query';
return match($method) {
'queryOne' => $this->connection->queryOne($sql, $params),
'queryColumn' => $this->connection->queryColumn($sql, $params),
'queryScalar' => $this->connection->queryScalar($sql, $params),
default => $this->connection->query($sql, $params),
};
}, $timeout);
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Parallel replica queries (if ReadWriteConnection is used)
*/
public function queryReplicas(string $sql, array $parameters = []): array
{
if (! ($this->connection instanceof ReadWriteConnection)) {
// Fallback to single query
return [$this->connection->query($sql, $parameters)];
}
$readConnections = $this->connection->getReadConnections();
if (count($readConnections) <= 1) {
return [$this->connection->query($sql, $parameters)];
}
$operations = [];
foreach ($readConnections as $i => $readConnection) {
$operations["replica_$i"] = fn () => $readConnection->query($sql, $parameters);
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Transaction with timeout
*/
public function transactionWithTimeout(callable $callback, ?Duration $timeout = null): mixed
{
$timeout ??= Duration::fromSeconds(30);
return $this->asyncService->withTimeout(function () use ($callback) {
$this->connection->beginTransaction();
try {
$result = $callback($this->connection);
$this->connection->commit();
return $result;
} catch (\Exception $e) {
$this->connection->rollback();
throw $e;
}
}, $timeout);
}
/**
* Parallel table statistics collection
*/
public function getTableStats(array $tables): array
{
$operations = [];
foreach ($tables as $table) {
$operations[$table] = function () use ($table) {
try {
$stats = [];
// Row count
$stats['row_count'] = $this->connection->queryScalar(
"SELECT COUNT(*) FROM `$table`"
);
// Table size (MySQL specific)
$sizeResult = $this->connection->queryOne(
"SELECT
ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) AS size_mb,
DATA_LENGTH,
INDEX_LENGTH
FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name = ?",
[$table]
);
$stats['size_mb'] = $sizeResult['size_mb'] ?? 0;
$stats['data_size'] = $sizeResult['DATA_LENGTH'] ?? 0;
$stats['index_size'] = $sizeResult['INDEX_LENGTH'] ?? 0;
return $stats;
} catch (\Exception $e) {
return ['error' => $e->getMessage()];
}
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Get statistics
*/
public function getStats(): array
{
return [
'async_enabled' => true,
'async_stats' => $this->asyncService->getStats(),
];
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Core\ValueObjects\Duration;
/**
* Fluent Builder für AsyncDatabase Operations
*
* Vereinfacht die Erstellung von komplexen parallelen Database Operations
*/
final class AsyncDatabaseBuilder
{
private array $queries = [];
private ?Duration $timeout = null;
private bool $useTransaction = false;
public function __construct(
private readonly AsyncDatabaseDecorator $db
) {
}
/**
* Füge SELECT Query hinzu
*/
public function select(string $key, string $sql, array $params = []): self
{
return $this->addQuery($key, $sql, $params, 'query');
}
/**
* Füge SELECT One Query hinzu
*/
public function selectOne(string $key, string $sql, array $params = []): self
{
return $this->addQuery($key, $sql, $params, 'queryOne');
}
/**
* Füge SELECT Column Query hinzu
*/
public function selectColumn(string $key, string $sql, array $params = []): self
{
return $this->addQuery($key, $sql, $params, 'queryColumn');
}
/**
* Füge SELECT Scalar Query hinzu
*/
public function selectScalar(string $key, string $sql, array $params = []): self
{
return $this->addQuery($key, $sql, $params, 'queryScalar');
}
/**
* Füge COUNT Query hinzu
*/
public function count(string $key, string $table, string $where = '1=1', array $params = []): self
{
$sql = "SELECT COUNT(*) FROM `$table` WHERE $where";
return $this->addQuery($key, $sql, $params, 'queryScalar');
}
/**
* Füge SUM Query hinzu
*/
public function sum(string $key, string $table, string $column, string $where = '1=1', array $params = []): self
{
$sql = "SELECT COALESCE(SUM(`$column`), 0) FROM `$table` WHERE $where";
return $this->addQuery($key, $sql, $params, 'queryScalar');
}
/**
* Füge AVG Query hinzu
*/
public function avg(string $key, string $table, string $column, string $where = '1=1', array $params = []): self
{
$sql = "SELECT COALESCE(AVG(`$column`), 0) FROM `$table` WHERE $where";
return $this->addQuery($key, $sql, $params, 'queryScalar');
}
/**
* Füge MAX Query hinzu
*/
public function max(string $key, string $table, string $column, string $where = '1=1', array $params = []): self
{
$sql = "SELECT MAX(`$column`) FROM `$table` WHERE $where";
return $this->addQuery($key, $sql, $params, 'queryScalar');
}
/**
* Füge MIN Query hinzu
*/
public function min(string $key, string $table, string $column, string $where = '1=1', array $params = []): self
{
$sql = "SELECT MIN(`$column`) FROM `$table` WHERE $where";
return $this->addQuery($key, $sql, $params, 'queryScalar');
}
/**
* Setze Timeout für alle Queries
*/
public function withTimeout(Duration $timeout): self
{
$this->timeout = $timeout;
return $this;
}
/**
* Führe alle Queries in einer Transaktion aus
*/
public function inTransaction(): self
{
$this->useTransaction = true;
return $this;
}
/**
* Führe alle Queries parallel aus
*/
public function execute(): array
{
if (empty($this->queries)) {
return [];
}
if ($this->useTransaction) {
return $this->executeInTransaction();
}
if ($this->timeout !== null) {
return $this->db->readAhead($this->queries, $this->timeout);
}
return $this->db->aggregate($this->queries);
}
/**
* Führe als Read-Ahead aus (mit Timeout Protection)
*/
public function executeAsReadAhead(): array
{
$timeout = $this->timeout ?? Duration::fromSeconds(10);
return $this->db->readAhead($this->queries, $timeout);
}
/**
* Reset Builder für Wiederverwendung
*/
public function reset(): self
{
$this->queries = [];
$this->timeout = null;
$this->useTransaction = false;
return $this;
}
/**
* Erstelle neuen Builder
*/
public static function create(AsyncDatabaseDecorator $db): self
{
return new self($db);
}
/**
* Helper für Dashboard Metrics
*/
public function dashboardMetrics(): self
{
return $this
->count('total_users', 'users')
->count('active_users', 'users', 'status = ?', ['active'])
->count('recent_orders', 'orders', 'created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)')
->sum('daily_revenue', 'orders', 'total', 'created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)')
->count('pending_orders', 'orders', 'status = ?', ['pending']);
}
/**
* Helper für User Analytics
*/
public function userAnalytics(int $userId): self
{
return $this
->selectOne('profile', 'SELECT id, name, email, created_at FROM users WHERE id = ?', [$userId])
->count('total_orders', 'orders', 'user_id = ?', [$userId])
->sum('total_spent', 'orders', 'total', 'user_id = ?', [$userId])
->selectOne('last_order', 'SELECT id, total, created_at FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [$userId])
->count('login_count', 'user_sessions', 'user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)', [$userId]);
}
/**
* Helper für Product Analytics
*/
public function productAnalytics(int $productId): self
{
return $this
->selectOne('product', 'SELECT * FROM products WHERE id = ?', [$productId])
->count('total_sales', 'order_items oi JOIN orders o ON oi.order_id = o.id', 'oi.product_id = ?', [$productId])
->sum('revenue', 'order_items oi JOIN orders o ON oi.order_id = o.id', 'oi.price * oi.quantity', 'oi.product_id = ?', [$productId])
->avg('avg_rating', 'reviews', 'rating', 'product_id = ?', [$productId])
->count('review_count', 'reviews', 'product_id = ?', [$productId])
->selectScalar('current_stock', 'SELECT quantity FROM inventory WHERE product_id = ?', [$productId]);
}
/**
* Helper für System Health Metrics
*/
public function systemHealth(): self
{
return $this
->count('error_logs_1h', 'error_logs', 'created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)')
->count('slow_queries_1h', 'slow_query_log', 'start_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)')
->count('active_sessions', 'user_sessions', 'created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)')
->selectScalar('db_size', "SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) FROM information_schema.TABLES WHERE table_schema = DATABASE()")
->count('failed_jobs', 'job_queue', 'status = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)', ['failed']);
}
/**
* Helper für E-commerce Analytics
*/
public function ecommerceAnalytics(\DateTime $date): self
{
$dateStr = $date->format('Y-m-d');
return $this
->count('daily_orders', 'orders', 'DATE(created_at) = ?', [$dateStr])
->sum('daily_revenue', 'orders', 'total', 'DATE(created_at) = ?', [$dateStr])
->count('new_customers', 'users', 'DATE(created_at) = ?', [$dateStr])
->selectScalar('avg_order_value', 'SELECT AVG(total) FROM orders WHERE DATE(created_at) = ?', [$dateStr])
->select('top_products', 'SELECT p.name, SUM(oi.quantity) as sales FROM order_items oi JOIN products p ON oi.product_id = p.id JOIN orders o ON oi.order_id = o.id WHERE DATE(o.created_at) = ? GROUP BY p.id ORDER BY sales DESC LIMIT 10', [$dateStr]);
}
private function addQuery(string $key, string $sql, array $params, string $method): self
{
$this->queries[$key] = [
'sql' => $sql,
'params' => $params,
'method' => $method,
];
return $this;
}
private function executeInTransaction(): array
{
if ($this->timeout !== null) {
return $this->db->transactionWithTimeout(function ($db) {
return $db->aggregate($this->queries);
}, $this->timeout);
}
return $this->db->transactionWithTimeout(function ($db) {
return $db->aggregate($this->queries);
});
}
}
// Extension function for easier access
if (! function_exists('asyncDb')) {
function asyncDb(AsyncDatabaseDecorator $db): AsyncDatabaseBuilder
{
return AsyncDatabaseBuilder::create($db);
}
}

View File

@@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Async\AsyncPromise;
use App\Framework\Async\AsyncService;
use App\Framework\Core\ValueObjects\Duration;
/**
* Async Decorator für Database ConnectionInterface
*
* Erweitert jede Database Connection um asynchrone Fähigkeiten:
* - Parallel read queries across replicas
* - Batch insert/update operations
* - Non-blocking writes
* - Parallel data aggregation
* - Read-ahead caching
* - Connection pool utilization
*/
final readonly class AsyncDatabaseDecorator implements ConnectionInterface
{
public function __construct(
private ConnectionInterface $connection,
private AsyncService $asyncService
) {
}
// === STANDARD SYNCHRONOUS INTERFACE ===
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();
}
// === ASYNC EXTENSIONS ===
/**
* Execute query asynchronously
*/
public function queryAsync(string $sql, array $parameters = []): AsyncPromise
{
return $this->asyncService->promise(
fn () => $this->connection->query($sql, $parameters)
);
}
/**
* Execute multiple queries in parallel
*/
public function queryMultiple(array $queries): array
{
$operations = [];
foreach ($queries as $key => $queryData) {
$sql = $queryData['sql'] ?? $queryData;
$params = $queryData['params'] ?? [];
$operations[$key] = fn () => $this->connection->query($sql, $params);
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Parallel data aggregation from multiple tables
*/
public function aggregate(array $namedQueries): array
{
$operations = [];
foreach ($namedQueries as $name => $queryData) {
$operations[$name] = function () use ($queryData) {
try {
$sql = $queryData['sql'] ?? $queryData;
$params = $queryData['params'] ?? [];
$method = $queryData['method'] ?? 'query';
$result = match($method) {
'queryOne' => $this->connection->queryOne($sql, $params),
'queryColumn' => $this->connection->queryColumn($sql, $params),
'queryScalar' => $this->connection->queryScalar($sql, $params),
default => $this->connection->query($sql, $params),
};
return [
'success' => true,
'data' => $result,
'error' => null,
];
} catch (\Exception $e) {
return [
'success' => false,
'data' => null,
'error' => $e->getMessage(),
];
}
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Batch insert operations
*/
public function batchInsert(string $table, array $columns, array $rows, int $batchSize = 100): array
{
if (empty($rows)) {
return [];
}
$batches = array_chunk($rows, $batchSize);
$placeholders = '(' . str_repeat('?,', count($columns) - 1) . '?)';
$operations = [];
foreach ($batches as $i => $batch) {
$operations["batch_$i"] = function () use ($table, $columns, $batch, $placeholders) {
$values = str_repeat($placeholders . ',', count($batch) - 1) . $placeholders;
$sql = "INSERT INTO `$table` (`" . implode('`, `', $columns) . "`) VALUES $values";
$params = [];
foreach ($batch as $row) {
$params = array_merge($params, array_values($row));
}
return $this->connection->execute($sql, $params);
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Batch update operations
*/
public function batchUpdate(string $table, array $updates, string $keyColumn = 'id', int $batchSize = 50): array
{
if (empty($updates)) {
return [];
}
$batches = array_chunk($updates, $batchSize);
$operations = [];
foreach ($batches as $i => $batch) {
$operations["batch_$i"] = function () use ($table, $batch, $keyColumn) {
$affected = 0;
foreach ($batch as $update) {
$keyValue = $update[$keyColumn];
unset($update[$keyColumn]);
$setParts = [];
$params = [];
foreach ($update as $column => $value) {
$setParts[] = "`$column` = ?";
$params[] = $value;
}
$params[] = $keyValue;
$sql = "UPDATE `$table` SET " . implode(', ', $setParts) . " WHERE `$keyColumn` = ?";
$affected += $this->connection->execute($sql, $params);
}
return $affected;
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Parallel read-ahead for related data
*/
public function readAhead(array $queries, ?Duration $timeout = null): array
{
$timeout ??= Duration::fromSeconds(10);
$operations = [];
foreach ($queries as $key => $queryData) {
$operations[$key] = function () use ($queryData, $timeout) {
return $this->asyncService->withTimeout(function () use ($queryData) {
$sql = $queryData['sql'] ?? $queryData;
$params = $queryData['params'] ?? [];
$method = $queryData['method'] ?? 'query';
return match($method) {
'queryOne' => $this->connection->queryOne($sql, $params),
'queryColumn' => $this->connection->queryColumn($sql, $params),
'queryScalar' => $this->connection->queryScalar($sql, $params),
default => $this->connection->query($sql, $params),
};
}, $timeout);
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Non-blocking write operations (fire and forget)
*/
public function executeAsync(string $sql, array $parameters = []): void
{
$this->asyncService->async(fn () => $this->connection->execute($sql, $parameters));
}
/**
* Parallel replica queries (if ReadWriteConnection is used)
*/
public function queryReplicas(string $sql, array $parameters = []): array
{
if (! ($this->connection instanceof ReadWriteConnection)) {
// Fallback to single query
return [$this->connection->query($sql, $parameters)];
}
$readConnections = $this->connection->getReadConnections();
if (count($readConnections) <= 1) {
return [$this->connection->query($sql, $parameters)];
}
$operations = [];
foreach ($readConnections as $i => $readConnection) {
$operations["replica_$i"] = fn () => $readConnection->query($sql, $parameters);
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Optimistic locking with retry
*/
public function optimisticUpdate(
string $table,
array $data,
array $conditions,
string $versionColumn = 'version',
int $maxRetries = 3
): bool {
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
// Read current version
$currentData = $this->queryOne(
"SELECT * FROM `$table` WHERE " . $this->buildWhereClause($conditions),
array_values($conditions)
);
if (! $currentData) {
return false;
}
$currentVersion = $currentData[$versionColumn];
$newVersion = $currentVersion + 1;
// Update with version check
$data[$versionColumn] = $newVersion;
$updateConditions = array_merge($conditions, [$versionColumn => $currentVersion]);
$setParts = [];
$params = [];
foreach ($data as $column => $value) {
$setParts[] = "`$column` = ?";
$params[] = $value;
}
$whereParts = [];
foreach ($updateConditions as $column => $value) {
$whereParts[] = "`$column` = ?";
$params[] = $value;
}
$sql = "UPDATE `$table` SET " . implode(', ', $setParts) .
" WHERE " . implode(' AND ', $whereParts);
$affected = $this->connection->execute($sql, $params);
if ($affected > 0) {
return true;
}
// Version conflict, retry
if ($attempt < $maxRetries) {
$this->asyncService->delay(Duration::fromMilliseconds(50 * $attempt))->getReturn();
}
} catch (\Exception $e) {
if ($attempt === $maxRetries) {
throw $e;
}
$this->asyncService->delay(Duration::fromMilliseconds(100 * $attempt))->getReturn();
}
}
return false;
}
/**
* Transaction with timeout
*/
public function transactionWithTimeout(callable $callback, ?Duration $timeout = null): mixed
{
$timeout ??= Duration::fromSeconds(30);
return $this->asyncService->withTimeout(function () use ($callback) {
$this->beginTransaction();
try {
$result = $callback($this);
$this->commit();
return $result;
} catch (\Exception $e) {
$this->rollback();
throw $e;
}
}, $timeout);
}
/**
* Parallel table statistics collection
*/
public function getTableStats(array $tables): array
{
$operations = [];
foreach ($tables as $table) {
$operations[$table] = function () use ($table) {
try {
$stats = [];
// Row count
$stats['row_count'] = $this->connection->queryScalar(
"SELECT COUNT(*) FROM `$table`"
);
// Table size (MySQL specific)
$sizeResult = $this->connection->queryOne(
"SELECT
ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) AS size_mb,
DATA_LENGTH,
INDEX_LENGTH
FROM information_schema.TABLES
WHERE table_schema = DATABASE() AND table_name = ?",
[$table]
);
$stats['size_mb'] = $sizeResult['size_mb'] ?? 0;
$stats['data_size'] = $sizeResult['DATA_LENGTH'] ?? 0;
$stats['index_size'] = $sizeResult['INDEX_LENGTH'] ?? 0;
return $stats;
} catch (\Exception $e) {
return ['error' => $e->getMessage()];
}
};
}
$results = $this->asyncService->parallel($operations);
return $results->await();
}
/**
* Get database statistics including async metrics
*/
public function getStats(): array
{
$baseStats = [];
if (method_exists($this->connection, 'getStatistics')) {
$baseStats = $this->connection->getStatistics();
}
return array_merge($baseStats, [
'async_enabled' => true,
'async_stats' => $this->asyncService->getStats(),
'connection_type' => get_class($this->connection),
'supports_read_replicas' => $this->connection instanceof ReadWriteConnection,
]);
}
/**
* Get the underlying connection
*/
public function getWrappedConnection(): ConnectionInterface
{
return $this->connection;
}
/**
* Build WHERE clause from conditions array
*/
private function buildWhereClause(array $conditions): string
{
$parts = [];
foreach (array_keys($conditions) as $column) {
$parts[] = "`$column` = ?";
}
return implode(' AND ', $parts);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Attributes;
use Attribute;
@@ -13,5 +15,6 @@ class Column
public bool $primary = false,
public bool $autoIncrement = false,
public bool $nullable = false
) {}
) {
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Attributes;
use Attribute;
@@ -10,5 +12,6 @@ class Entity
public function __construct(
public ?string $tableName = null,
public string $idColumn = 'id'
) {}
) {
}
}

View File

@@ -14,5 +14,6 @@ final readonly class Type
public ?string $foreignKey = null,
public ?string $localKey = null,
public string $type = 'hasMany'
) {}
) {
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Backup;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Filesystem\FilePath;
/**
* Metadata for backup files
*/
final readonly class BackupMetadata
{
public function __construct(
public FilePath|string $filename,
public Byte $originalSize,
public Byte $compressedSize,
public bool $encrypted,
public bool $compressed,
public string $checksum,
public string $createdAt,
public BackupOptions $options
) {
}
/**
* Get filename as FilePath object
*/
public function getFilePath(): FilePath
{
return $this->filename instanceof FilePath ? $this->filename : FilePath::create($this->filename);
}
/**
* Get filename as string
*/
public function getFilename(): string
{
return $this->filename instanceof FilePath ? $this->filename->getFilename() : basename($this->filename);
}
/**
* Get compression ratio as percentage
*/
public function getCompressionRatio(): float
{
if ($this->originalSize->isEmpty()) {
return 0.0;
}
return 100 - $this->compressedSize->percentOf($this->originalSize);
}
/**
* Get space saved by compression
*/
public function getSpaceSaved(): Byte
{
if ($this->originalSize->lessThan($this->compressedSize)) {
return Byte::zero();
}
return $this->originalSize->subtract($this->compressedSize);
}
/**
* Check if backup is efficiently compressed (>10% reduction)
*/
public function isEfficientlyCompressed(): bool
{
return $this->getCompressionRatio() > 10.0;
}
/**
* Get backup summary string
*/
public function getSummary(): string
{
$parts = [];
if ($this->compressed) {
$ratio = round($this->getCompressionRatio(), 1);
$parts[] = "compressed ({$ratio}%)";
}
if ($this->encrypted) {
$parts[] = "encrypted";
}
$status = empty($parts) ? 'plain' : implode(', ', $parts);
return "{$this->compressedSize->toHumanReadable()} ({$status})";
}
/**
* @return array{filename: string, originalSize: int, compressedSize: int, encrypted: bool, compressed: bool, checksum: string, createdAt: string, options: array}
* Convert to array for serialization
*/
public function toArray(): array
{
return [
'filename' => $this->filename instanceof FilePath ? $this->filename->toString() : $this->filename,
'originalSize' => $this->originalSize->toBytes(),
'compressedSize' => $this->compressedSize->toBytes(),
'encrypted' => $this->encrypted,
'compressed' => $this->compressed,
'checksum' => $this->checksum,
'createdAt' => $this->createdAt,
'options' => $this->options->toArray(),
];
}
/**
* Create from an array (for JSON deserialization)
*/
public static function fromArray(array $data): self
{
return new self(
filename: $data['filename'],
originalSize: Byte::fromBytes($data['originalSize']),
compressedSize: Byte::fromBytes($data['compressedSize']),
encrypted: $data['encrypted'],
compressed: $data['compressed'],
checksum: $data['checksum'],
createdAt: $data['createdAt'],
options: BackupOptions::fromArray($data['options'])
);
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Backup;
/**
* Configuration options for database backups
*/
final readonly class BackupOptions
{
public function __construct(
public bool $includeStructure = true,
public bool $includeData = true,
public bool $compress = true,
public bool $encrypt = false,
public ?array $includeTables = null,
public ?array $excludeTables = null,
public bool $singleTransaction = true,
public bool $routines = true,
public bool $triggers = true,
public bool $events = false,
public int $maxMemory = 256 * 1024 * 1024, // 256MB
) {
}
/**
* Create options for structure-only backup
*/
public static function structureOnly(): self
{
return new self(
includeStructure: true,
includeData: false,
compress: true,
encrypt: false
);
}
/**
* Create options for data-only backup
*/
public static function dataOnly(): self
{
return new self(
includeStructure: false,
includeData: true,
compress: true,
encrypt: false
);
}
/**
* Create options for secure encrypted backup
*/
public static function encrypted(): self
{
return new self(
includeStructure: true,
includeData: true,
compress: true,
encrypt: true
);
}
/**
* Create options for quick uncompressed backup
*/
public static function quick(): self
{
return new self(
includeStructure: true,
includeData: true,
compress: false,
encrypt: false,
singleTransaction: false,
routines: false,
triggers: false
);
}
/**
* Create options with specific table inclusion
*/
public function withTables(array $tables): self
{
return new self(
includeStructure: $this->includeStructure,
includeData: $this->includeData,
compress: $this->compress,
encrypt: $this->encrypt,
includeTables: $tables,
excludeTables: $this->excludeTables,
singleTransaction: $this->singleTransaction,
routines: $this->routines,
triggers: $this->triggers,
events: $this->events,
maxMemory: $this->maxMemory
);
}
/**
* Create options excluding specific tables
*/
public function excludingTables(array $tables): self
{
return new self(
includeStructure: $this->includeStructure,
includeData: $this->includeData,
compress: $this->compress,
encrypt: $this->encrypt,
includeTables: $this->includeTables,
excludeTables: $tables,
singleTransaction: $this->singleTransaction,
routines: $this->routines,
triggers: $this->triggers,
events: $this->events,
maxMemory: $this->maxMemory
);
}
/**
* Enable encryption
*/
public function withEncryption(): self
{
return new self(
includeStructure: $this->includeStructure,
includeData: $this->includeData,
compress: $this->compress,
encrypt: true,
includeTables: $this->includeTables,
excludeTables: $this->excludeTables,
singleTransaction: $this->singleTransaction,
routines: $this->routines,
triggers: $this->triggers,
events: $this->events,
maxMemory: $this->maxMemory
);
}
/**
* Convert to array for serialization
*/
public function toArray(): array
{
return [
'includeStructure' => $this->includeStructure,
'includeData' => $this->includeData,
'compress' => $this->compress,
'encrypt' => $this->encrypt,
'includeTables' => $this->includeTables,
'excludeTables' => $this->excludeTables,
'singleTransaction' => $this->singleTransaction,
'routines' => $this->routines,
'triggers' => $this->triggers,
'events' => $this->events,
'maxMemory' => $this->maxMemory,
];
}
/**
* Create from array
*/
public static function fromArray(array $data): self
{
return new self(
includeStructure: $data['includeStructure'] ?? true,
includeData: $data['includeData'] ?? true,
compress: $data['compress'] ?? true,
encrypt: $data['encrypt'] ?? false,
includeTables: $data['includeTables'] ?? null,
excludeTables: $data['excludeTables'] ?? null,
singleTransaction: $data['singleTransaction'] ?? true,
routines: $data['routines'] ?? true,
triggers: $data['triggers'] ?? true,
events: $data['events'] ?? false,
maxMemory: $data['maxMemory'] ?? 256 * 1024 * 1024,
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Backup;
/**
* Result of a backup or restore operation
*/
final readonly class BackupResult
{
public function __construct(
public bool $success,
public ?string $filePath = null,
public ?BackupMetadata $metadata = null,
public string $message = '',
public ?\Throwable $error = null
) {
}
public static function success(string $filePath, BackupMetadata $metadata, string $message = ''): self
{
return new self(
success: true,
filePath: $filePath,
metadata: $metadata,
message: $message
);
}
public static function failure(string $message, ?\Throwable $error = null): self
{
return new self(
success: false,
message: $message,
error: $error
);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Backup;
/**
* Retention policy for backup cleanup
*/
final readonly class BackupRetentionPolicy
{
public function __construct(
public int $maxAgeDays = 30,
public int $maxCount = 10,
public bool $keepDaily = true,
public bool $keepWeekly = true,
public bool $keepMonthly = true
) {
}
/**
* Default retention policy for production
*/
public static function production(): self
{
return new self(
maxAgeDays: 90,
maxCount: 50,
keepDaily: true,
keepWeekly: true,
keepMonthly: true
);
}
/**
* Retention policy for development
*/
public static function development(): self
{
return new self(
maxAgeDays: 7,
maxCount: 5,
keepDaily: false,
keepWeekly: false,
keepMonthly: false
);
}
/**
* Conservative retention policy
*/
public static function conservative(): self
{
return new self(
maxAgeDays: 365,
maxCount: 100,
keepDaily: true,
keepWeekly: true,
keepMonthly: true
);
}
/**
* Minimal retention policy
*/
public static function minimal(): self
{
return new self(
maxAgeDays: 3,
maxCount: 3,
keepDaily: false,
keepWeekly: false,
keepMonthly: false
);
}
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Backup\Console;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Database\Backup\BackupOptions;
use App\Framework\Database\Backup\BackupRetentionPolicy;
use App\Framework\Database\Backup\DatabaseBackupService;
use App\Framework\Filesystem\FilePath;
/**
* Console commands for database backup management
*/
final readonly class BackupCommand
{
public function __construct(
private DatabaseBackupService $backupService
) {
}
#[ConsoleCommand('backup:create', 'Create a database backup')]
public function create(ConsoleInput $input, ConsoleOutput $output): int
{
$output->writeLine('🗄️ Creating database backup...', ConsoleColor::CYAN);
$output->newLine();
try {
// Parse options
$options = $this->parseBackupOptions($input);
// Show backup options
$this->displayBackupOptions($output, $options);
// Create backup
$result = $this->backupService->createBackup($options);
if ($result->success) {
$output->writeSuccess('✅ Backup created successfully!');
$output->newLine();
if ($result->metadata) {
$output->writeLine('📊 Backup Details:', ConsoleColor::CYAN);
$output->writeLine("• File: {$result->metadata->filename}");
$output->writeLine("• Size: {$result->metadata->getSummary()}");
$output->writeLine("• Original: {$result->metadata->originalSize->toHumanReadable()}");
if ($result->metadata->compressed) {
$ratio = round($result->metadata->getCompressionRatio(), 1);
$saved = $result->metadata->getSpaceSaved();
$output->writeLine("• Saved: {$saved->toHumanReadable()} ({$ratio}% compression)");
}
$output->writeLine("• Checksum: {$result->metadata->checksum}");
}
return 0;
} else {
$output->writeError('❌ Backup failed: ' . $result->message);
return 1;
}
} catch (\Throwable $e) {
$output->writeError('❌ Backup failed: ' . $e->getMessage());
return 1;
}
}
#[ConsoleCommand('backup:restore', 'Restore database from backup')]
public function restore(ConsoleInput $input, ConsoleOutput $output): int
{
$backupFile = $input->getArgument(0);
if (! $backupFile) {
$backups = $this->backupService->listBackups();
if (empty($backups)) {
$output->writeError('❌ No backups found');
return 1;
}
$output->writeLine('📋 Available backups:', ConsoleColor::CYAN);
foreach ($backups as $i => $backup) {
$output->writeLine(sprintf(
'%d. %s (%s) - %s',
$i + 1,
$backup['file'],
$backup['metadata']?->getSummary() ?? Byte::fromBytes($backup['size'])->toHumanReadable(),
$backup['created']
));
}
$choice = $output->askQuestion('Select backup number to restore:');
$index = (int) $choice - 1;
if (! isset($backups[$index])) {
$output->writeError('❌ Invalid backup selection');
return 1;
}
$backupFile = $backups[$index]['path'];
}
$backupPath = FilePath::create($backupFile);
if (! $backupPath->exists()) {
$output->writeError("❌ Backup file not found: {$backupFile}");
return 1;
}
// Confirm restore
$output->writeWarning('⚠️ This will overwrite the current database!');
if (! $output->confirm('Are you sure you want to restore?', false)) {
$output->writeInfo('Restore cancelled');
return 0;
}
try {
$output->writeLine('🔄 Restoring database from backup...', ConsoleColor::CYAN);
$result = $this->backupService->restoreBackup($backupFile);
if ($result->success) {
$output->writeSuccess('✅ Database restored successfully!');
return 0;
} else {
$output->writeError('❌ Restore failed: ' . $result->message);
return 1;
}
} catch (\Throwable $e) {
$output->writeError('❌ Restore failed: ' . $e->getMessage());
return 1;
}
}
#[ConsoleCommand('backup:list', 'List all available backups')]
public function list(ConsoleInput $input, ConsoleOutput $output): int
{
try {
$backups = $this->backupService->listBackups();
if (empty($backups)) {
$output->writeInfo(' No backups found');
return 0;
}
$output->writeLine('📋 Available backups:', ConsoleColor::CYAN);
$output->newLine();
foreach ($backups as $backup) {
$metadata = $backup['metadata'];
$output->writeLine("📁 {$backup['file']}", ConsoleColor::WHITE);
$output->writeLine(" Created: {$backup['created']}", ConsoleColor::GRAY);
if ($metadata) {
$output->writeLine(" Size: {$metadata->getSummary()}", ConsoleColor::GRAY);
$output->writeLine(" Checksum: {$metadata->checksum}", ConsoleColor::GRAY);
} else {
$fileSize = Byte::fromBytes($backup['size']);
$output->writeLine(" Size: {$fileSize->toHumanReadable()}", ConsoleColor::GRAY);
}
$output->newLine();
}
$output->writeLine("Total backups: " . count($backups), ConsoleColor::CYAN);
return 0;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to list backups: ' . $e->getMessage());
return 1;
}
}
#[ConsoleCommand('backup:cleanup', 'Clean up old backups based on retention policy')]
public function cleanup(ConsoleInput $input, ConsoleOutput $output): int
{
try {
// Parse retention policy
$maxDays = (int) $input->getOption('days', 30);
$maxCount = (int) $input->getOption('count', 10);
$policy = new BackupRetentionPolicy(
maxAgeDays: $maxDays,
maxCount: $maxCount
);
$output->writeLine('🧹 Cleaning up old backups...', ConsoleColor::CYAN);
$output->writeLine("Policy: Keep {$maxCount} backups, max {$maxDays} days old");
$output->newLine();
$deletedCount = $this->backupService->cleanupBackups($policy);
if ($deletedCount > 0) {
$output->writeSuccess("✅ Deleted {$deletedCount} old backup(s)");
} else {
$output->writeInfo(' No backups needed cleanup');
}
return 0;
} catch (\Throwable $e) {
$output->writeError('❌ Cleanup failed: ' . $e->getMessage());
return 1;
}
}
#[ConsoleCommand('backup:verify', 'Verify backup integrity')]
public function verify(ConsoleInput $input, ConsoleOutput $output): int
{
$backupFile = $input->getArgument(0);
if (! $backupFile) {
$output->writeError('❌ Please specify a backup file to verify');
return 1;
}
$backupPath = FilePath::create($backupFile);
if (! $backupPath->exists()) {
$output->writeError("❌ Backup file not found: {$backupFile}");
return 1;
}
try {
$output->writeLine('🔍 Verifying backup integrity...', ConsoleColor::CYAN);
// Load metadata
$metadataPath = $backupPath->withExtension($backupPath->getExtension() . '.meta');
if ($metadataPath->exists()) {
$jsonData = file_get_contents($metadataPath->toString());
$metadataArray = json_decode($jsonData, true);
if ($metadataArray) {
$metadata = \App\Framework\Database\Backup\BackupMetadata::fromArray($metadataArray);
// Verify checksum
$currentChecksum = hash_file('sha256', $backupPath->toString());
if ($currentChecksum === $metadata->checksum) {
$output->writeSuccess('✅ Backup integrity verified');
$output->writeLine("• File: {$metadata->filename}");
$output->writeLine("• Size: {$metadata->getSummary()}");
$output->writeLine("• Created: {$metadata->createdAt}");
return 0;
} else {
$output->writeError('❌ Backup integrity check failed - checksum mismatch');
return 1;
}
} else {
$output->writeWarning('⚠️ Could not parse metadata file');
}
} else {
$output->writeWarning('⚠️ No metadata file found - cannot verify integrity');
}
// Basic file check
$size = $backupPath->getSize();
if ($size->isNotEmpty()) {
$output->writeInfo(" File exists and has size: {$size->toHumanReadable()}");
return 0;
} else {
$output->writeError('❌ Backup file is empty');
return 1;
}
} catch (\Throwable $e) {
$output->writeError('❌ Verification failed: ' . $e->getMessage());
return 1;
}
}
/**
* Parse backup options from console input
*/
private function parseBackupOptions(ConsoleInput $input): BackupOptions
{
$structureOnly = $input->hasOption('structure-only');
$dataOnly = $input->hasOption('data-only');
$encrypt = $input->hasOption('encrypt');
$noCompress = $input->hasOption('no-compress');
if ($structureOnly) {
return BackupOptions::structureOnly();
}
if ($dataOnly) {
return BackupOptions::dataOnly();
}
$options = new BackupOptions(
compress: ! $noCompress,
encrypt: $encrypt
);
return $encrypt ? $options->withEncryption() : $options;
}
/**
* Display backup options
*/
private function displayBackupOptions(ConsoleOutput $output, BackupOptions $options): void
{
$output->writeLine('⚙️ Backup Options:', ConsoleColor::CYAN);
$output->writeLine("• Structure: " . ($options->includeStructure ? 'Yes' : 'No'));
$output->writeLine("• Data: " . ($options->includeData ? 'Yes' : 'No'));
$output->writeLine("• Compression: " . ($options->compress ? 'Yes' : 'No'));
$output->writeLine("• Encryption: " . ($options->encrypt ? 'Yes' : 'No'));
$output->newLine();
}
}

View File

@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Backup;
use App\Framework\Config\SecretManager;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\DatabaseManager;
use App\Framework\DateTime\Clock;
use App\Framework\Filesystem\Directory;
use App\Framework\Filesystem\File;
use App\Framework\Random\RandomGenerator;
use RuntimeException;
/**
* Comprehensive database backup service with encryption and compression
*/
final readonly class DatabaseBackupService
{
private const BACKUP_EXTENSION = '.sql';
private const ENCRYPTED_EXTENSION = '.enc';
private const COMPRESSED_EXTENSION = '.gz';
public function __construct(
private DatabaseManager $databaseManager,
private ?SecretManager $secretManager = null,
private ?Clock $clock = null,
private ?RandomGenerator $randomGenerator = null,
private string $backupPath = 'storage/backups'
) {
}
/**
* Create a database backup with optional encryption and compression
*/
public function createBackup(BackupOptions $options): BackupResult
{
$connection = $this->databaseManager->getConnection();
$timestamp = ($this->clock ?? new \App\Framework\DateTime\SystemClock())->now()->format('Y_m_d_H_i_s');
// Generate backup filename
$baseFilename = "backup_{$timestamp}";
$filename = $this->generateBackupFilename($baseFilename, $options);
$fullPath = $this->backupPath . '/' . $filename;
// Ensure backup directory exists
Directory::create(dirname($fullPath));
try {
// Create SQL dump
$sqlContent = $this->createSqlDump($connection, $options);
// Process content (compress, encrypt)
$processedContent = $this->processBackupContent($sqlContent, $options);
// Write to file
File::write($fullPath, $processedContent);
// Create metadata
$metadata = $this->createBackupMetadata($fullPath, $options, strlen($sqlContent));
// Save metadata
$this->saveBackupMetadata($metadata);
return new BackupResult(
success: true,
filePath: $fullPath,
metadata: $metadata,
message: "Backup created successfully: {$filename}"
);
} catch (\Throwable $e) {
// Cleanup failed backup file
if (file_exists($fullPath)) {
unlink($fullPath);
}
throw new RuntimeException("Backup failed: " . $e->getMessage(), 0, $e);
}
}
/**
* Restore database from backup file
*/
public function restoreBackup(string $backupPath, ?BackupOptions $options = null): BackupResult
{
if (! file_exists($backupPath)) {
throw new RuntimeException("Backup file not found: {$backupPath}");
}
$connection = $this->databaseManager->getConnection();
$options ??= new BackupOptions();
try {
// Read and process backup file
$content = File::read($backupPath);
$sqlContent = $this->processRestoreContent($content, $backupPath, $options);
// Execute SQL statements
$this->executeSqlRestore($connection, $sqlContent, $options);
return new BackupResult(
success: true,
filePath: $backupPath,
message: "Database restored successfully from: " . basename($backupPath)
);
} catch (\Throwable $e) {
throw new RuntimeException("Restore failed: " . $e->getMessage(), 0, $e);
}
}
/**
* List all available backups with metadata
*/
public function listBackups(): array
{
$backupDir = $this->backupPath;
if (! is_dir($backupDir)) {
return [];
}
$backups = [];
$files = glob($backupDir . '/backup_*');
foreach ($files as $file) {
if (is_file($file)) {
$metadata = $this->loadBackupMetadata($file);
$backups[] = [
'file' => basename($file),
'path' => $file,
'size' => filesize($file),
'created' => date('Y-m-d H:i:s', filemtime($file)),
'metadata' => $metadata,
];
}
}
// Sort by creation time (newest first)
usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
return $backups;
}
/**
* Delete old backups based on retention policy
*/
public function cleanupBackups(BackupRetentionPolicy $policy): int
{
$backups = $this->listBackups();
$deletedCount = 0;
$now = time();
foreach ($backups as $backup) {
$age = $now - filemtime($backup['path']);
$shouldDelete = false;
// Check retention rules
if ($policy->maxAgeDays > 0 && $age > ($policy->maxAgeDays * 24 * 3600)) {
$shouldDelete = true;
}
if ($policy->maxCount > 0 && $deletedCount >= (count($backups) - $policy->maxCount)) {
$shouldDelete = true;
}
if ($shouldDelete) {
$this->deleteBackup($backup['path']);
$deletedCount++;
}
}
return $deletedCount;
}
/**
* Create SQL dump based on database driver
*/
private function createSqlDump(ConnectionInterface $connection, BackupOptions $options): string
{
// For now, we'll use a generic approach
// In a real implementation, you'd have driver-specific dump methods
$sql = '';
// Get all tables
$tables = $this->getTables($connection);
foreach ($tables as $table) {
if ($options->includeTables && ! in_array($table, $options->includeTables)) {
continue;
}
if ($options->excludeTables && in_array($table, $options->excludeTables)) {
continue;
}
// Add table structure
if ($options->includeStructure) {
$sql .= $this->getTableStructure($connection, $table);
}
// Add table data
if ($options->includeData) {
$sql .= $this->getTableData($connection, $table);
}
}
return $sql;
}
/**
* Process backup content (compress, encrypt)
*/
private function processBackupContent(string $content, BackupOptions $options): string
{
$processed = $content;
// Compress if requested
if ($options->compress) {
$processed = gzencode($processed, 9);
if ($processed === false) {
throw new RuntimeException('Failed to compress backup content');
}
}
// Encrypt if requested and SecretManager is available
if ($options->encrypt && $this->secretManager !== null) {
$processed = $this->secretManager->encryptSecret($processed);
}
return $processed;
}
/**
* Process restore content (decrypt, decompress)
*/
private function processRestoreContent(string $content, string $filePath, BackupOptions $options): string
{
$processed = $content;
// Auto-detect encryption
$isEncrypted = $this->secretManager?->isEncrypted($content) ?? false;
if ($isEncrypted && $this->secretManager !== null) {
$processed = $this->secretManager->getSecret('TEMP_RESTORE', $content);
}
// Auto-detect compression
$isCompressed = str_ends_with($filePath, self::COMPRESSED_EXTENSION);
if ($isCompressed) {
$decompressed = gzdecode($processed);
if ($decompressed === false) {
throw new RuntimeException('Failed to decompress backup content');
}
$processed = $decompressed;
}
return $processed;
}
/**
* Generate backup filename with appropriate extensions
*/
private function generateBackupFilename(string $baseFilename, BackupOptions $options): string
{
$filename = $baseFilename . self::BACKUP_EXTENSION;
if ($options->compress) {
$filename .= self::COMPRESSED_EXTENSION;
}
if ($options->encrypt) {
$filename .= self::ENCRYPTED_EXTENSION;
}
return $filename;
}
/**
* Create backup metadata
*/
private function createBackupMetadata(string $filePath, BackupOptions $options, int $originalSize): BackupMetadata
{
$fileSize = filesize($filePath);
return new BackupMetadata(
filename: basename($filePath),
originalSize: Byte::fromBytes($originalSize),
compressedSize: Byte::fromBytes($fileSize),
encrypted: $options->encrypt,
compressed: $options->compress,
checksum: hash_file('sha256', $filePath),
createdAt: ($this->clock ?? new \App\Framework\DateTime\SystemClock())->now()->format('Y-m-d H:i:s'),
options: $options
);
}
/**
* Save backup metadata to separate file
*/
private function saveBackupMetadata(BackupMetadata $metadata): void
{
$metadataFile = $this->backupPath . '/' . $metadata->filename . '.meta';
$jsonData = json_encode($metadata, JSON_PRETTY_PRINT);
File::write($metadataFile, $jsonData);
}
/**
* Load backup metadata from file
*/
private function loadBackupMetadata(string $backupPath): ?BackupMetadata
{
$metadataFile = $backupPath . '.meta';
if (! file_exists($metadataFile)) {
return null;
}
$jsonData = File::read($metadataFile);
$data = json_decode($jsonData, true);
if (! $data) {
return null;
}
return BackupMetadata::fromArray($data);
}
/**
* Delete backup and its metadata
*/
private function deleteBackup(string $backupPath): void
{
if (file_exists($backupPath)) {
unlink($backupPath);
}
$metadataFile = $backupPath . '.meta';
if (file_exists($metadataFile)) {
unlink($metadataFile);
}
}
/**
* Get all tables from database
*/
private function getTables(ConnectionInterface $connection): array
{
// This is a simplified implementation
// In reality, you'd need driver-specific queries
$result = $connection->query("SHOW TABLES");
$tables = [];
while ($row = $result->fetch()) {
$tables[] = array_values($row)[0];
}
return $tables;
}
/**
* Get table structure SQL
*/
private function getTableStructure(ConnectionInterface $connection, string $table): string
{
$result = $connection->query("SHOW CREATE TABLE `{$table}`");
$row = $result->fetch();
return "-- Table structure for `{$table}`\n" .
"DROP TABLE IF EXISTS `{$table}`;\n" .
$row['Create Table'] . ";\n\n";
}
/**
* Get table data SQL
*/
private function getTableData(ConnectionInterface $connection, string $table): string
{
$result = $connection->query("SELECT * FROM `{$table}`");
$sql = "-- Data for table `{$table}`\n";
while ($row = $result->fetch()) {
$values = array_map(fn ($value) => $value === null ? 'NULL' : "'" . addslashes($value) . "'", $row);
$sql .= "INSERT INTO `{$table}` VALUES (" . implode(', ', $values) . ");\n";
}
return $sql . "\n";
}
/**
* Execute SQL statements for restore
*/
private function executeSqlRestore(ConnectionInterface $connection, string $sql, BackupOptions $options): void
{
// Split SQL into individual statements
$statements = array_filter(
array_map('trim', explode(';', $sql)),
fn ($stmt) => ! empty($stmt) && ! str_starts_with($stmt, '--')
);
foreach ($statements as $statement) {
if (! empty(trim($statement))) {
$connection->query($statement);
}
}
}
}

View File

@@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\Metadata\PropertyMetadata;
final readonly class BatchRelationLoader
{
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private IdentityMap $identityMap,
private HydratorInterface $hydrator
) {
}
/**
* Preload a specific relation for multiple entities in batches
*
* @param array<object> $entities
* @param string $relationName
*/
public function preloadRelation(array $entities, string $relationName): void
{
if (empty($entities)) {
return;
}
$entityClass = get_class($entities[0]);
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$relationMetadata = $this->getRelationMetadata($metadata, $relationName);
if ($relationMetadata === null) {
return; // Relation not found
}
match ($relationMetadata->relationType) {
'hasMany' => $this->preloadHasMany($entities, $relationMetadata),
'belongsTo' => $this->preloadBelongsTo($entities, $relationMetadata),
'one-to-one' => $this->preloadOneToOne($entities, $relationMetadata),
default => null
};
}
/**
* Preload hasMany relations (1:N)
* Example: Post -> Comments
*/
private function preloadHasMany(array $entities, PropertyMetadata $relationMetadata): void
{
// Collect all local keys (e.g., post IDs)
$localKeys = array_filter(array_map(
fn ($entity) => $this->getLocalKey($entity, $relationMetadata),
$entities
));
if (empty($localKeys)) {
return;
}
// Load all related entities in one query
// WHERE foreign_key IN (1, 2, 3, ...)
$allRelations = $this->findByQuery(
$relationMetadata->relationTargetClass,
[$relationMetadata->relationForeignKey => $localKeys]
);
// Group relations by foreign key
$groupedRelations = $this->groupByForeignKey($allRelations, $relationMetadata->relationForeignKey);
// Set relations on entities
foreach ($entities as $entity) {
$localKey = $this->getLocalKey($entity, $relationMetadata);
$relations = $groupedRelations[$localKey] ?? [];
$this->setRelationOnEntity($entity, $relationMetadata->name, $relations);
}
}
/**
* Preload belongsTo relations (N:1)
* Example: Comment -> Post
*/
private function preloadBelongsTo(array $entities, PropertyMetadata $relationMetadata): void
{
// Collect all foreign keys (e.g., post IDs from comments)
$foreignKeys = array_filter(array_unique(array_map(
fn ($entity) => $this->getForeignKey($entity, $relationMetadata),
$entities
)));
if (empty($foreignKeys)) {
return;
}
// Load all related entities in one query
// WHERE id IN (1, 2, 3, ...)
$allRelations = $this->findByQuery(
$relationMetadata->relationTargetClass,
['id' => $foreignKeys] // Assuming 'id' is the primary key
);
// Index by primary key
$indexedRelations = $this->indexByPrimaryKey($allRelations);
// Set relations on entities
foreach ($entities as $entity) {
$foreignKey = $this->getForeignKey($entity, $relationMetadata);
$relation = $indexedRelations[$foreignKey] ?? null;
$this->setRelationOnEntity($entity, $relationMetadata->name, $relation);
}
}
/**
* Preload one-to-one relations
* Example: User -> Profile
*/
private function preloadOneToOne(array $entities, PropertyMetadata $relationMetadata): void
{
// Similar to belongsTo but expecting single result
$localKeys = array_filter(array_map(
fn ($entity) => $this->getLocalKey($entity, $relationMetadata),
$entities
));
if (empty($localKeys)) {
return;
}
$allRelations = $this->findByQuery(
$relationMetadata->relationTargetClass,
[$relationMetadata->relationForeignKey => $localKeys]
);
// Group by foreign key but expect only one result per key
$groupedRelations = $this->groupByForeignKey($allRelations, $relationMetadata->relationForeignKey);
foreach ($entities as $entity) {
$localKey = $this->getLocalKey($entity, $relationMetadata);
$relation = $groupedRelations[$localKey][0] ?? null; // Take first (should be only one)
$this->setRelationOnEntity($entity, $relationMetadata->name, $relation);
}
}
private function getRelationMetadata(EntityMetadata $metadata, string $relationName): ?PropertyMetadata
{
foreach ($metadata->properties as $property) {
if ($property->name === $relationName && $property->isRelation) {
return $property;
}
}
return null;
}
private function getLocalKey(object $entity, PropertyMetadata $relationMetadata): mixed
{
return $this->getPropertyValue($entity, $relationMetadata->relationLocalKey);
}
private function getForeignKey(object $entity, PropertyMetadata $relationMetadata): mixed
{
return $this->getPropertyValue($entity, $relationMetadata->relationForeignKey);
}
private function getPropertyValue(object $entity, string $propertyName): mixed
{
$reflection = new \ReflectionClass($entity);
// Try direct property access first
if ($reflection->hasProperty($propertyName)) {
$property = $reflection->getProperty($propertyName);
return $property->getValue($entity);
}
// Try getter method
$getterMethod = 'get' . ucfirst($propertyName);
if ($reflection->hasMethod($getterMethod)) {
return $entity->$getterMethod();
}
// Try ID property as fallback for primary keys
if ($propertyName === 'id' && $reflection->hasProperty('id')) {
$property = $reflection->getProperty('id');
return $property->getValue($entity);
}
return null;
}
/**
* Group entities by foreign key value
* @param array<object> $entities
* @param string $foreignKeyProperty
* @return array<mixed, array<object>>
*/
private function groupByForeignKey(array $entities, string $foreignKeyProperty): array
{
$grouped = [];
foreach ($entities as $entity) {
$key = $this->getPropertyValue($entity, $foreignKeyProperty);
if ($key !== null) {
$grouped[$key][] = $entity;
}
}
return $grouped;
}
/**
* Index entities by their primary key
* @param array<object> $entities
* @return array<mixed, object>
*/
private function indexByPrimaryKey(array $entities): array
{
$indexed = [];
foreach ($entities as $entity) {
$key = $this->getPropertyValue($entity, 'id'); // Assuming 'id' is primary key
if ($key !== null) {
$indexed[$key] = $entity;
}
}
return $indexed;
}
/**
* Internal method to execute database query and hydrate entities
* @param string $entityClass
* @param array $criteria
* @return array<object>
*/
private function findByQuery(string $entityClass, array $criteria): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT * FROM {$metadata->tableName}";
$params = [];
if (! empty($criteria)) {
$conditions = [];
foreach ($criteria as $field => $value) {
if (is_array($value)) {
// Handle IN queries for batch loading
$placeholders = str_repeat('?,', count($value) - 1) . '?';
$columnName = $metadata->getColumnName($field);
$conditions[] = "{$columnName} IN ({$placeholders})";
$params = array_merge($params, array_values($value));
} else {
$columnName = $metadata->getColumnName($field);
$conditions[] = "{$columnName} = ?";
$params[] = $value;
}
}
$query .= " WHERE " . implode(' AND ', $conditions);
}
$result = $this->databaseManager->getConnection()->query($query, $params);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
// Check identity map first to avoid duplicates
if ($this->identityMap->has($entityClass, $idValue)) {
$entities[] = $this->identityMap->get($entityClass, $idValue);
} else {
$entity = $this->hydrator->hydrate($metadata, $data);
$this->identityMap->set($entityClass, $idValue, $entity);
$entities[] = $entity;
}
}
return $entities;
}
private function setRelationOnEntity(object $entity, string $relationName, mixed $relationValue): void
{
$reflection = new \ReflectionClass($entity);
// Try setter methods first
$setterMethod = 'set' . ucfirst($relationName);
if ($reflection->hasMethod($setterMethod)) {
$entity->$setterMethod($relationValue);
return;
}
// For readonly entities, we might need to use a different approach
// This is tricky with readonly properties - we might need to store in a cache
// For now, we'll skip setting on readonly entities
if ($reflection->hasProperty($relationName)) {
$property = $reflection->getProperty($relationName);
if (! $property->isReadOnly()) {
$property->setValue($entity, $relationValue);
}
}
}
}

View File

@@ -49,13 +49,16 @@ final class CacheAdapterStrategy implements CacheStrategy
if ($value === null) {
$this->stats['misses']++;
return null;
}
$this->stats['hits']++;
return $value->value;
} catch (\Throwable) {
$this->stats['misses']++;
return null;
}
}
@@ -91,9 +94,10 @@ final class CacheAdapterStrategy implements CacheStrategy
public function invalidatePattern(string $pattern): int
{
// Fallback für Cache-Implementierungen ohne Pattern-Support
if (!method_exists($this->cache, 'deleteByPattern')) {
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
}
@@ -101,6 +105,7 @@ final class CacheAdapterStrategy implements CacheStrategy
$searchPattern = $this->keyPrefix . '*' . $pattern . '*';
$deleted = $this->cache->deleteByPattern($searchPattern);
$this->stats['invalidations'] += $deleted;
return $deleted;
} catch (\Throwable) {
return 0;
@@ -114,6 +119,7 @@ final class CacheAdapterStrategy implements CacheStrategy
if (method_exists($this->cache, 'deleteByTag')) {
$deleted = $this->cache->deleteByTag('database_query');
$this->stats['invalidations'] += $deleted;
return;
}
@@ -121,6 +127,7 @@ final class CacheAdapterStrategy implements CacheStrategy
if (method_exists($this->cache, 'deleteByPattern')) {
$deleted = $this->cache->deleteByPattern($this->keyPrefix . '*');
$this->stats['invalidations'] += $deleted;
return;
}
@@ -160,6 +167,7 @@ final class CacheAdapterStrategy implements CacheStrategy
private function calculateHitRatio(): float
{
$total = $this->stats['hits'] + $this->stats['misses'];
return $total > 0 ? ($this->stats['hits'] / $total) : 0.0;
}
@@ -171,6 +179,7 @@ final class CacheAdapterStrategy implements CacheStrategy
// Wenn TaggedCache verfügbar ist, nutze es
if (method_exists($cache, 'tags')) {
$taggedCache = $cache->tags($tags);
return new self($taggedCache, $keyPrefix);
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\Cache\ValueObjects\CacheMetricsSnapshot;
use App\Framework\Database\Cache\ValueObjects\CacheOverviewMetrics;
use App\Framework\Database\Cache\ValueObjects\CachePerformanceMetrics;
use App\Framework\Database\Cache\ValueObjects\CacheRecommendation;
use App\Framework\Database\Cache\ValueObjects\EntityCacheMetrics;
use App\Framework\Database\Cache\ValueObjects\EntityMetrics;
use App\Framework\Database\Cache\ValueObjects\MemoryUsageMetrics;
use App\Framework\Database\Cache\ValueObjects\QueryCacheMetrics;
use App\Framework\Database\Cache\ValueObjects\QueryMetrics;
use App\Framework\Database\Cache\ValueObjects\RecommendationCategory;
use App\Framework\Database\Cache\ValueObjects\RecommendationImpact;
use App\Framework\Database\Cache\ValueObjects\RecommendationType;
use App\Framework\Database\Cache\ValueObjects\RegionMetrics;
use App\Framework\Database\Events\Timestamp;
use App\Framework\DateTime\Clock;
use App\Framework\Performance\MemoryMonitor;
/**
* Cache performance metrics and monitoring
*/
final class CacheMetrics
{
private array $entityMetrics = [];
private array $queryMetrics = [];
private int $totalOperations = 0;
private Timestamp $startTime;
public function __construct(
private readonly SecondLevelCacheInterface $cache,
private readonly EntityCacheManager $cacheManager,
private readonly Clock $clock,
private readonly MemoryMonitor $memoryMonitor
) {
$this->startTime = Timestamp::fromClock($this->clock);
}
/**
* Get comprehensive cache metrics
*/
public function getMetrics(): CacheMetricsSnapshot
{
$cacheStats = $this->cache->getStats();
$regionStats = $this->cache->getRegionStats();
return new CacheMetricsSnapshot(
overview: $this->getOverviewMetrics($cacheStats),
performance: $this->getPerformanceMetrics($cacheStats),
entities: $this->getEntityMetrics(),
queries: $this->getQueryMetrics(),
regions: $this->getRegionMetrics($regionStats),
recommendations: $this->getRecommendations($cacheStats)
);
}
/**
* Get cache overview metrics
*/
private function getOverviewMetrics(array $cacheStats): CacheOverviewMetrics
{
$totalHits = $cacheStats['hits'] + $cacheStats['query_hits'];
$totalMisses = $cacheStats['misses'] + $cacheStats['query_misses'];
$totalRequests = $totalHits + $totalMisses;
$hitRatio = $totalRequests > 0
? Percentage::fromRatio($totalHits, $totalRequests)
: Percentage::zero();
$uptime = $this->startTime->age($this->clock);
$requestsPerSecond = $uptime->toSeconds() > 0
? $totalRequests / $uptime->toSeconds()
: 0.0;
return new CacheOverviewMetrics(
enabled: $this->cacheManager->isEnabled(),
totalRequests: $totalRequests,
totalHits: $totalHits,
totalMisses: $totalMisses,
hitRatio: $hitRatio,
uptime: $uptime,
requestsPerSecond: $requestsPerSecond
);
}
/**
* Get cache performance metrics
*/
private function getPerformanceMetrics(array $cacheStats): CachePerformanceMetrics
{
$entityHitRatio = ($cacheStats['hits'] + $cacheStats['misses']) > 0
? Percentage::fromRatio($cacheStats['hits'], $cacheStats['hits'] + $cacheStats['misses'])
: Percentage::zero();
$queryHitRatio = ($cacheStats['query_hits'] + $cacheStats['query_misses']) > 0
? Percentage::fromRatio($cacheStats['query_hits'], $cacheStats['query_hits'] + $cacheStats['query_misses'])
: Percentage::zero();
return new CachePerformanceMetrics(
entityCache: new EntityCacheMetrics(
hits: $cacheStats['hits'],
misses: $cacheStats['misses'],
hitRatio: $entityHitRatio,
puts: $cacheStats['puts'],
evictions: $cacheStats['evictions']
),
queryCache: new QueryCacheMetrics(
hits: $cacheStats['query_hits'],
misses: $cacheStats['query_misses'],
hitRatio: $queryHitRatio
),
memory: $this->getMemoryMetrics()
);
}
/**
* Get memory usage metrics
*/
private function getMemoryMetrics(): MemoryUsageMetrics
{
$currentUsage = $this->memoryMonitor->getCurrentMemory();
$peakUsage = $this->memoryMonitor->getPeakMemory();
$memoryLimit = $this->memoryMonitor->getMemoryLimit();
$usagePercentage = $memoryLimit->isEmpty()
? Percentage::zero()
: Percentage::fromDecimal($currentUsage->toBytes() / $memoryLimit->toBytes());
return new MemoryUsageMetrics(
current: $currentUsage,
peak: $peakUsage,
limit: $memoryLimit,
usagePercentage: $usagePercentage
);
}
/**
* Get entity-specific metrics
*/
private function getEntityMetrics(): array
{
$metrics = [];
foreach ($this->entityMetrics as $entityClass => $stats) {
$totalAccess = $stats['hits'] + $stats['misses'];
$hitRatio = $totalAccess > 0
? Percentage::fromRatio($stats['hits'], $totalAccess)
: Percentage::zero();
$avgAccessTime = $stats['access_count'] > 0
? Duration::fromSeconds($stats['total_access_time'] / $stats['access_count'])
: Duration::zero();
$metrics[$entityClass] = new EntityMetrics(
hits: $stats['hits'],
misses: $stats['misses'],
hitRatio: $hitRatio,
cacheSize: $stats['cached_count'] ?? 0,
averageAccessTime: $avgAccessTime
);
}
return $metrics;
}
/**
* Get query-specific metrics
*/
private function getQueryMetrics(): QueryMetrics
{
return new QueryMetrics(
totalCachedQueries: count($this->queryMetrics),
topQueries: $this->getTopQueries(10),
slowQueries: $this->getSlowQueries(5)
);
}
/**
* Get region-specific metrics
*/
private function getRegionMetrics(array $regionStats): array
{
$metrics = [];
foreach ($regionStats as $regionName => $stats) {
$metrics[$regionName] = new RegionMetrics(
name: $regionName,
enabled: $stats['enabled'],
defaultTtl: Duration::fromSeconds($stats['default_ttl']),
maxSize: $stats['max_size'],
efficiencyScore: Percentage::from($this->calculateRegionEfficiency($regionName))
);
}
return $metrics;
}
/**
* Get cache optimization recommendations
*/
private function getRecommendations(array $cacheStats): array
{
$recommendations = [];
$hitRatio = Percentage::fromDecimal($this->cacheManager->getCacheHitRatio());
// Low hit ratio
if ($hitRatio->isBelow(Percentage::from(30.0))) {
$recommendations[] = new CacheRecommendation(
type: RecommendationType::WARNING,
category: RecommendationCategory::PERFORMANCE,
message: 'Low cache hit ratio (<30%). Consider adjusting TTL or cache strategies.',
impact: RecommendationImpact::HIGH
);
}
// High eviction rate
$evictionRate = $cacheStats['puts'] > 0
? Percentage::fromRatio($cacheStats['evictions'], $cacheStats['puts'])
: Percentage::zero();
if ($evictionRate->isAbove(Percentage::from(20.0))) {
$recommendations[] = new CacheRecommendation(
type: RecommendationType::WARNING,
category: RecommendationCategory::MEMORY,
message: 'High eviction rate. Consider increasing cache size or reducing TTL.',
impact: RecommendationImpact::MEDIUM
);
}
// Good performance
if ($hitRatio->isAbove(Percentage::from(80.0))) {
$recommendations[] = new CacheRecommendation(
type: RecommendationType::SUCCESS,
category: RecommendationCategory::PERFORMANCE,
message: 'Excellent cache performance! Hit ratio >80%.',
impact: RecommendationImpact::POSITIVE
);
}
return $recommendations;
}
/**
* Record entity access for metrics
*/
public function recordEntityAccess(string $entityClass, bool $cacheHit, ?Duration $accessTime = null): void
{
if (! isset($this->entityMetrics[$entityClass])) {
$this->entityMetrics[$entityClass] = [
'hits' => 0,
'misses' => 0,
'total_access_time' => 0.0,
'access_count' => 0,
];
}
if ($cacheHit) {
$this->entityMetrics[$entityClass]['hits']++;
} else {
$this->entityMetrics[$entityClass]['misses']++;
}
$accessTimeSeconds = $accessTime?->toSeconds() ?? 0.0;
$this->entityMetrics[$entityClass]['total_access_time'] += $accessTimeSeconds;
$this->entityMetrics[$entityClass]['access_count']++;
$this->totalOperations++;
}
/**
* Get top queries by access frequency
*/
private function getTopQueries(int $limit): array
{
$queries = $this->queryMetrics;
uasort($queries, function ($a, $b) {
return ($b['hits'] + $b['misses']) <=> ($a['hits'] + $a['misses']);
});
return array_slice($queries, 0, $limit, true);
}
/**
* Get slow queries by execution time
*/
private function getSlowQueries(int $limit): array
{
$queries = $this->queryMetrics;
uasort($queries, function ($a, $b) {
$avgTimeA = $a['execution_count'] > 0 ? $a['total_execution_time'] / $a['execution_count'] : 0;
$avgTimeB = $b['execution_count'] > 0 ? $b['total_execution_time'] / $b['execution_count'] : 0;
return $avgTimeB <=> $avgTimeA;
});
return array_slice($queries, 0, $limit, true);
}
/**
* Calculate region efficiency score
*/
private function calculateRegionEfficiency(string $regionName): float
{
// Simplified efficiency calculation
return 75.0; // Placeholder
}
/**
* Reset all metrics
*/
public function reset(): void
{
$this->entityMetrics = [];
$this->queryMetrics = [];
$this->totalOperations = 0;
$this->startTime = Timestamp::fromClock($this->clock);
}
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\ValueObjects\Duration;
/**
* Cache region for organizing cache entries by entity types
*/
final readonly class CacheRegion
{
public function __construct(
public string $name,
public Duration $defaultTtl,
public int $maxSize = 10000,
public bool $enabled = true
) {
}
/**
* Create cache region for fast-changing data (short TTL)
*/
public static function fastChanging(string $name): self
{
return new self(
name: $name,
defaultTtl: Duration::fromMinutes(5),
maxSize: 5000,
enabled: true
);
}
/**
* Create cache region for slow-changing data (long TTL)
*/
public static function slowChanging(string $name): self
{
return new self(
name: $name,
defaultTtl: Duration::fromHours(1),
maxSize: 15000,
enabled: true
);
}
/**
* Create cache region for read-only data (very long TTL)
*/
public static function readOnly(string $name): self
{
return new self(
name: $name,
defaultTtl: Duration::fromHours(24),
maxSize: 20000,
enabled: true
);
}
/**
* Create disabled cache region
*/
public static function disabled(string $name): self
{
return new self(
name: $name,
defaultTtl: Duration::zero(),
maxSize: 0,
enabled: false
);
}
/**
* Get cache key prefix for this region
*/
public function getKeyPrefix(): string
{
return "region:{$this->name}:";
}
/**
* Get entity cache key
*/
public function getEntityKey(string $entityClass, mixed $entityId): string
{
$className = $this->getShortClassName($entityClass);
return $this->getKeyPrefix() . "entity:{$className}:{$entityId}";
}
/**
* Get query cache key
*/
public function getQueryKey(string $cacheKey): string
{
return $this->getKeyPrefix() . "query:{$cacheKey}";
}
/**
* Get collection cache key
*/
public function getCollectionKey(string $entityClass, string $cacheKey): string
{
$className = $this->getShortClassName($entityClass);
return $this->getKeyPrefix() . "collection:{$className}:{$cacheKey}";
}
/**
* Get pattern for invalidating all entities of a class
*/
public function getEntityClassPattern(string $entityClass): string
{
$className = $this->getShortClassName($entityClass);
return $this->getKeyPrefix() . "entity:{$className}:*";
}
/**
* Get pattern for invalidating all cache entries in this region
*/
public function getRegionPattern(): string
{
return $this->getKeyPrefix() . "*";
}
private function getShortClassName(string $entityClass): string
{
$parts = explode('\\', $entityClass);
return end($parts);
}
}

View File

@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\EntityManager;
/**
* Cache warmup strategies for preloading frequently accessed data
*/
final class CacheWarmupStrategy
{
public function __construct(
private readonly EntityManager $entityManager,
private readonly EntityCacheManager $cacheManager
) {
}
/**
* Warm up cache with commonly accessed entities
*/
public function warmUpEntities(array $warmupStrategies): array
{
$results = [];
foreach ($warmupStrategies as $entityClass => $strategy) {
$results[$entityClass] = $this->executeWarmupStrategy($entityClass, $strategy);
}
return $results;
}
/**
* Execute specific warmup strategy for entity class
*/
private function executeWarmupStrategy(string $entityClass, array $strategy): array
{
$strategyType = $strategy['type'] ?? 'recent';
$limit = $strategy['limit'] ?? 100;
$ttl = isset($strategy['ttl']) ? Duration::fromSeconds($strategy['ttl']) : null;
return match ($strategyType) {
'recent' => $this->warmUpRecentEntities($entityClass, $limit, $ttl),
'popular' => $this->warmUpPopularEntities($entityClass, $limit, $ttl),
'critical' => $this->warmUpCriticalEntities($entityClass, $strategy['ids'] ?? [], $ttl),
'all' => $this->warmUpAllEntities($entityClass, $limit, $ttl),
default => ['status' => 'unknown_strategy', 'warmed' => 0]
};
}
/**
* Warm up recently created/updated entities
*/
private function warmUpRecentEntities(string $entityClass, int $limit, ?Duration $ttl): array
{
try {
// Load recent entities (assuming there's a created_at or updated_at field)
$entities = $this->entityManager->findBy(
$entityClass,
[],
['created_at' => 'DESC'],
$limit
);
$warmedCount = 0;
foreach ($entities as $entity) {
if ($this->cacheEntity($entity, $ttl)) {
$warmedCount++;
}
}
return [
'status' => 'success',
'warmed' => $warmedCount,
'total' => count($entities),
];
} catch (\Throwable $e) {
return [
'status' => 'error',
'error' => $e->getMessage(),
'warmed' => 0,
];
}
}
/**
* Warm up popular entities (most accessed)
*/
private function warmUpPopularEntities(string $entityClass, int $limit, ?Duration $ttl): array
{
try {
// This would require access statistics - simplified implementation
$entities = $this->entityManager->findBy(
$entityClass,
[],
null,
$limit
);
$warmedCount = 0;
foreach ($entities as $entity) {
if ($this->cacheEntity($entity, $ttl)) {
$warmedCount++;
}
}
return [
'status' => 'success',
'warmed' => $warmedCount,
'total' => count($entities),
];
} catch (\Throwable $e) {
return [
'status' => 'error',
'error' => $e->getMessage(),
'warmed' => 0,
];
}
}
/**
* Warm up specific critical entities by ID
*/
private function warmUpCriticalEntities(string $entityClass, array $entityIds, ?Duration $ttl): array
{
if (empty($entityIds)) {
return ['status' => 'success', 'warmed' => 0, 'total' => 0];
}
$warmedCount = 0;
$errorCount = 0;
foreach ($entityIds as $entityId) {
try {
$entity = $this->entityManager->find($entityClass, $entityId);
if ($entity !== null && $this->cacheEntity($entity, $ttl)) {
$warmedCount++;
}
} catch (\Throwable $e) {
$errorCount++;
}
}
return [
'status' => $errorCount > 0 ? 'partial' : 'success',
'warmed' => $warmedCount,
'errors' => $errorCount,
'total' => count($entityIds),
];
}
/**
* Warm up all entities (dangerous for large tables)
*/
private function warmUpAllEntities(string $entityClass, int $limit, ?Duration $ttl): array
{
try {
$entities = $this->entityManager->findBy($entityClass, [], null, $limit);
$warmedCount = 0;
foreach ($entities as $entity) {
if ($this->cacheEntity($entity, $ttl)) {
$warmedCount++;
}
}
return [
'status' => 'success',
'warmed' => $warmedCount,
'total' => count($entities),
];
} catch (\Throwable $e) {
return [
'status' => 'error',
'error' => $e->getMessage(),
'warmed' => 0,
];
}
}
/**
* Cache a single entity
*/
private function cacheEntity(object $entity, ?Duration $ttl): bool
{
try {
$this->cacheManager->cacheEntity($entity);
return true;
} catch (\Throwable) {
return false;
}
}
/**
* Get default warmup strategies for common entity types
*/
public static function getDefaultStrategies(): array
{
return [
'User' => [
'type' => 'recent',
'limit' => 50,
'ttl' => 1800, // 30 minutes
],
'Config' => [
'type' => 'all',
'limit' => 500,
'ttl' => 86400, // 24 hours
],
'Translation' => [
'type' => 'all',
'limit' => 1000,
'ttl' => 86400, // 24 hours
],
'Product' => [
'type' => 'popular',
'limit' => 200,
'ttl' => 3600, // 1 hour
],
'Category' => [
'type' => 'all',
'limit' => 100,
'ttl' => 7200, // 2 hours
],
];
}
/**
* Create warmup strategy for specific entity IDs
*/
public static function createCriticalStrategy(array $entityIds, int $ttlSeconds = 3600): array
{
return [
'type' => 'critical',
'ids' => $entityIds,
'ttl' => $ttlSeconds,
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
/**
* Cache key generator for entities
*/
final readonly class EntityCacheKey
{
public function __construct(
public string $entityClass,
public mixed $entityId,
public ?string $region = null
) {
}
/**
* Create cache key for single entity
*/
public static function forEntity(string $entityClass, mixed $entityId, ?string $region = null): self
{
return new self($entityClass, $entityId, $region);
}
/**
* Create cache key for entity collection
*/
public static function forCollection(string $entityClass, array $criteria = [], ?array $orderBy = null, ?int $limit = null, ?int $offset = null): string
{
$keyParts = [
'collection',
self::getShortClassName($entityClass),
md5(serialize($criteria)),
];
if ($orderBy !== null) {
$keyParts[] = md5(serialize($orderBy));
}
if ($limit !== null) {
$keyParts[] = "limit:{$limit}";
}
if ($offset !== null) {
$keyParts[] = "offset:{$offset}";
}
return implode(':', $keyParts);
}
/**
* Create cache key for query result
*/
public static function forQuery(string $sql, array $parameters = []): string
{
$paramHash = empty($parameters) ? 'no-params' : md5(serialize($parameters));
$sqlHash = md5($sql);
return "query:{$sqlHash}:{$paramHash}";
}
/**
* Create cache key for relation
*/
public static function forRelation(string $ownerClass, mixed $ownerId, string $relationName): string
{
$ownerClassName = self::getShortClassName($ownerClass);
return "relation:{$ownerClassName}:{$ownerId}:{$relationName}";
}
/**
* Generate the actual cache key string
*/
public function toString(): string
{
$className = self::getShortClassName($this->entityClass);
$keyParts = ['entity', $className, (string) $this->entityId];
if ($this->region !== null) {
array_unshift($keyParts, $this->region);
}
return implode(':', $keyParts);
}
/**
* Get versioned cache key (includes entity version for optimistic locking)
*/
public function toVersionedString(int $version): string
{
return $this->toString() . ':v' . $version;
}
/**
* Get invalidation pattern for entity class
*/
public static function getEntityClassPattern(string $entityClass, ?string $region = null): string
{
$className = self::getShortClassName($entityClass);
$pattern = "entity:{$className}:*";
if ($region !== null) {
$pattern = "{$region}:{$pattern}";
}
return $pattern;
}
/**
* Get invalidation pattern for relations
*/
public static function getRelationPattern(string $ownerClass, mixed $ownerId): string
{
$ownerClassName = self::getShortClassName($ownerClass);
return "relation:{$ownerClassName}:{$ownerId}:*";
}
private static function getShortClassName(string $entityClass): string
{
$parts = explode('\\', $entityClass);
return end($parts);
}
public function __toString(): string
{
return $this->toString();
}
}

View File

@@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\IdentityMap;
use App\Framework\Database\Metadata\MetadataRegistry;
/**
* Manages entity caching with smart invalidation and TTL strategies
*/
final class EntityCacheManager
{
/** @var array<string, CacheStrategy> Cache strategies per entity type */
private array $strategies = [];
public function __construct(
private readonly SecondLevelCacheInterface $cache,
private readonly IdentityMap $identityMap,
private readonly MetadataRegistry $metadataRegistry,
private readonly bool $enabled = true
) {
$this->initializeDefaultStrategies();
}
/**
* Find entity with cache check
*/
public function findEntity(string $entityClass, mixed $entityId, callable $databaseLoader): ?object
{
if (! $this->enabled) {
return $databaseLoader();
}
// Check identity map first
if ($this->identityMap->has($entityClass, $entityId)) {
return $this->identityMap->get($entityClass, $entityId);
}
// Check second-level cache
$cachedEntity = $this->cache->getEntity($entityClass, $entityId);
if ($cachedEntity !== null) {
// Add to identity map
$this->identityMap->add($cachedEntity, $entityId);
return $cachedEntity;
}
// Load from database
$entity = $databaseLoader();
if ($entity !== null) {
// Cache the entity
$this->cacheEntity($entity);
// Add to identity map
$this->identityMap->add($entity, $entityId);
}
return $entity;
}
/**
* Find entities collection with caching
*/
public function findCollection(
string $entityClass,
array $criteria,
?array $orderBy,
?int $limit,
?int $offset,
callable $databaseLoader
): array {
if (! $this->enabled) {
return $databaseLoader();
}
$cacheKey = EntityCacheKey::forCollection($entityClass, $criteria, $orderBy, $limit, $offset);
// Check cache first
$cachedCollection = $this->cache->getCollection($entityClass, $cacheKey);
if ($cachedCollection !== null) {
// Add entities to identity map
foreach ($cachedCollection as $entity) {
$entityId = $this->extractEntityId($entity);
if (! $this->identityMap->has($entityClass, $entityId)) {
$this->identityMap->add($entity, $entityId);
}
}
return $cachedCollection;
}
// Load from database
$entities = $databaseLoader();
// Cache the collection if it's worth caching
if ($this->shouldCacheCollection($entities, $criteria)) {
$ttl = $this->getCollectionCacheTtl($entityClass, count($entities));
$this->cache->putCollection($entityClass, $entities, $cacheKey, $ttl);
}
// Cache individual entities and add to identity map
foreach ($entities as $entity) {
$this->cacheEntity($entity);
$entityId = $this->extractEntityId($entity);
if (! $this->identityMap->has($entityClass, $entityId)) {
$this->identityMap->add($entity, $entityId);
}
}
return $entities;
}
/**
* Cache an entity
*/
public function cacheEntity(object $entity): void
{
if (! $this->enabled) {
return;
}
$entityClass = $entity::class;
$entityId = $this->extractEntityId($entity);
if ($entityId === null) {
return; // Cannot cache entities without ID
}
$strategy = $this->getStrategyForEntity($entityClass);
$ttl = $this->getEntityCacheTtl($entityClass);
$this->cache->putEntity($entityClass, $entityId, $entity, $ttl);
}
/**
* Evict entity from cache
*/
public function evictEntity(object $entity): void
{
if (! $this->enabled) {
return;
}
$entityClass = $entity::class;
$entityId = $this->extractEntityId($entity);
if ($entityId !== null) {
$this->cache->evictEntity($entityClass, $entityId);
}
// Also evict from identity map
$this->identityMap->remove($entityClass, $entityId);
}
/**
* Evict all entities of a class
*/
public function evictEntityClass(string $entityClass): int
{
if (! $this->enabled) {
return 0;
}
// Clear from identity map
$this->identityMap->clear($entityClass);
// Clear from second-level cache
return $this->cache->evictEntityClass($entityClass);
}
/**
* Warm up cache with commonly accessed entities
*/
public function warmUpCache(array $warmUpStrategies): void
{
if (! $this->enabled) {
return;
}
foreach ($warmUpStrategies as $entityClass => $strategy) {
$this->executeWarmUpStrategy($entityClass, $strategy);
}
}
/**
* Get cache hit ratio for performance monitoring
*/
public function getCacheHitRatio(): float
{
$stats = $this->cache->getStats();
$totalAccess = $stats['hits'] + $stats['misses'];
if ($totalAccess === 0) {
return 0.0;
}
return $stats['hits'] / $totalAccess;
}
/**
* Register cache strategy for specific entity type
*/
public function registerStrategy(string $entityClass, CacheStrategy $strategy): void
{
$this->strategies[$entityClass] = $strategy;
}
/**
* Extract entity ID using metadata
*/
private function extractEntityId(object $entity): mixed
{
$entityClass = $entity::class;
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$idProperty = $metadata->getIdProperty();
if ($idProperty === null) {
return null;
}
$reflection = new \ReflectionClass($entityClass);
$property = $reflection->getProperty($idProperty->name);
$property->setAccessible(true);
return $property->getValue($entity);
}
/**
* Get cache strategy for entity
*/
private function getStrategyForEntity(string $entityClass): string
{
if (isset($this->strategies[$entityClass])) {
return $this->strategies[$entityClass];
}
return 'default';
}
/**
* Get TTL for entity caching
*/
private function getEntityCacheTtl(string $entityClass): Duration
{
$strategy = $this->getStrategyForEntity($entityClass);
return match ($strategy) {
'fast_changing' => Duration::fromMinutes(5),
'slow_changing' => Duration::fromHours(1),
'read_only' => Duration::fromHours(24),
default => Duration::fromMinutes(30)
};
}
/**
* Get TTL for collection caching
*/
private function getCollectionCacheTtl(string $entityClass, int $collectionSize): Duration
{
$baseTtl = $this->getEntityCacheTtl($entityClass);
// Larger collections get shorter TTL
if ($collectionSize > 1000) {
return Duration::fromSeconds($baseTtl->toSeconds() / 4);
}
if ($collectionSize > 100) {
return Duration::fromSeconds($baseTtl->toSeconds() / 2);
}
return $baseTtl;
}
/**
* Determine if collection should be cached
*/
private function shouldCacheCollection(array $entities, array $criteria): bool
{
// Don't cache empty collections
if (empty($entities)) {
return false;
}
// Don't cache very large collections
if (count($entities) > 5000) {
return false;
}
// Don't cache collections with complex criteria
if (count($criteria) > 5) {
return false;
}
return true;
}
/**
* Initialize default cache strategies
*/
private function initializeDefaultStrategies(): void
{
$this->strategies = [
'User' => 'fast_changing',
'Session' => 'fast_changing',
'Config' => 'read_only',
'Translation' => 'read_only',
'default' => 'slow_changing',
];
}
/**
* Execute warm-up strategy for entity class
*/
private function executeWarmUpStrategy(string $entityClass, array $strategy): void
{
// Implementation would depend on specific warm-up requirements
// This could include loading frequently accessed entities,
// pre-computing common queries, etc.
}
/**
* Enable or disable caching
*/
public function setEnabled(bool $enabled): void
{
$this->enabled = $enabled;
}
/**
* Check if caching is enabled
*/
public function isEnabled(): bool
{
return $this->enabled;
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\ValueObjects\Duration;
/**
* Null implementation of second-level cache (disabled caching)
*/
final class NullSecondLevelCache implements SecondLevelCacheInterface
{
public function putEntity(string $entityClass, mixed $entityId, object $entity, ?Duration $ttl = null): bool
{
return false;
}
public function getEntity(string $entityClass, mixed $entityId): ?object
{
return null;
}
public function hasEntity(string $entityClass, mixed $entityId): bool
{
return false;
}
public function evictEntity(string $entityClass, mixed $entityId): bool
{
return false;
}
public function putQueryResult(QueryCacheKey $key, array $result, ?Duration $ttl = null): bool
{
return false;
}
public function getQueryResult(QueryCacheKey $key): ?array
{
return null;
}
public function hasQueryResult(QueryCacheKey $key): bool
{
return false;
}
public function evictQueryResult(QueryCacheKey $key): bool
{
return false;
}
public function putCollection(string $entityClass, array $entities, string $cacheKey, ?Duration $ttl = null): bool
{
return false;
}
public function getCollection(string $entityClass, string $cacheKey): ?array
{
return null;
}
public function hasCollection(string $entityClass, string $cacheKey): bool
{
return false;
}
public function evictCollection(string $entityClass, string $cacheKey): bool
{
return false;
}
public function evictEntityClass(string $entityClass): int
{
return 0;
}
public function clear(): bool
{
return true;
}
public function getStats(): array
{
return [
'hits' => 0,
'misses' => 0,
'puts' => 0,
'evictions' => 0,
'query_hits' => 0,
'query_misses' => 0,
];
}
public function getRegionStats(): array
{
return [];
}
}

View File

@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\ValueObjects\Duration;
/**
* Query result cache with smart invalidation
*/
final class QueryCache
{
/** @var array<string, int> Query execution statistics */
private array $queryStats = [];
public function __construct(
private readonly SecondLevelCacheInterface $cache,
private readonly bool $enabled = true
) {
}
/**
* Execute query with caching
*/
public function query(string $sql, array $parameters, callable $databaseExecutor, ?Duration $ttl = null): array
{
if (! $this->enabled) {
return $databaseExecutor();
}
$cacheKey = $this->createQueryCacheKey($sql, $parameters);
// Check cache first
$cached = $this->cache->getQueryResult($cacheKey);
if ($cached !== null) {
$this->recordCacheHit($sql);
return $cached;
}
// Execute query
$result = $databaseExecutor();
// Cache result if appropriate
if ($this->shouldCacheQuery($sql, $result)) {
$effectiveTtl = $ttl ?? $this->getQueryCacheTtl($sql);
$this->cache->putQueryResult($cacheKey, $result, $effectiveTtl);
}
$this->recordCacheMiss($sql);
return $result;
}
/**
* Invalidate queries that might be affected by entity changes
*/
public function invalidateForEntity(string $entityClass, string $operation = 'update'): void
{
if (! $this->enabled) {
return;
}
// This would need more sophisticated pattern matching
// For now, we invalidate based on table name patterns
$tableName = $this->getTableNameFromEntity($entityClass);
$patterns = [
"query:*{$tableName}*",
"query:*SELECT*{$tableName}*",
"query:*FROM*{$tableName}*",
"query:*JOIN*{$tableName}*",
];
foreach ($patterns as $pattern) {
$this->invalidateByPattern($pattern);
}
}
/**
* Get query cache statistics
*/
public function getQueryStats(): array
{
return $this->queryStats;
}
/**
* Clear all query cache
*/
public function clear(): bool
{
$this->queryStats = [];
return $this->cache->clear();
}
/**
* Enable or disable query caching
*/
public function setEnabled(bool $enabled): void
{
$this->enabled = $enabled;
}
/**
* Check if query caching is enabled
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Create cache key for query
*/
private function createQueryCacheKey(string $sql, array $parameters): QueryCacheKey
{
return new QueryCacheKey(
sql: $sql,
parameters: $parameters,
hash: $this->hashQuery($sql, $parameters)
);
}
/**
* Generate query hash for caching
*/
private function hashQuery(string $sql, array $parameters): string
{
$normalizedSql = $this->normalizeSql($sql);
$paramHash = empty($parameters) ? 'no-params' : md5(serialize($parameters));
return 'query:' . md5($normalizedSql) . ':' . $paramHash;
}
/**
* Normalize SQL for consistent caching
*/
private function normalizeSql(string $sql): string
{
// Remove extra whitespace and normalize case
$normalized = preg_replace('/\s+/', ' ', trim($sql));
$normalized = strtoupper($normalized);
// Remove comments
$normalized = preg_replace('/--.*$/m', '', $normalized);
$normalized = preg_replace('/\/\*.*?\*\//s', '', $normalized);
return trim($normalized);
}
/**
* Determine if query should be cached
*/
private function shouldCacheQuery(string $sql, array $result): bool
{
$normalizedSql = $this->normalizeSql($sql);
// Don't cache empty results
if (empty($result)) {
return false;
}
// Don't cache very large result sets
if (count($result) > 10000) {
return false;
}
// Only cache SELECT statements
if (! str_starts_with($normalizedSql, 'SELECT')) {
return false;
}
// Don't cache queries with user-specific content
$userSpecificPatterns = [
'USER_ID',
'SESSION_ID',
'CURRENT_TIMESTAMP',
'NOW()',
'RAND()',
'RANDOM()',
];
foreach ($userSpecificPatterns as $pattern) {
if (str_contains($normalizedSql, $pattern)) {
return false;
}
}
return true;
}
/**
* Get TTL for query caching based on query type
*/
private function getQueryCacheTtl(string $sql): Duration
{
$normalizedSql = $this->normalizeSql($sql);
// Configuration and static data - long TTL
if ($this->isConfigurationQuery($normalizedSql)) {
return Duration::fromHours(24);
}
// Aggregation queries - medium TTL
if ($this->isAggregationQuery($normalizedSql)) {
return Duration::fromMinutes(30);
}
// Complex joins - short TTL
if ($this->isComplexQuery($normalizedSql)) {
return Duration::fromMinutes(10);
}
// Default TTL
return Duration::fromMinutes(15);
}
/**
* Check if query is for configuration data
*/
private function isConfigurationQuery(string $sql): bool
{
$configTables = ['config', 'settings', 'translations', 'permissions'];
foreach ($configTables as $table) {
if (str_contains($sql, strtoupper($table))) {
return true;
}
}
return false;
}
/**
* Check if query contains aggregations
*/
private function isAggregationQuery(string $sql): bool
{
$aggregateFunctions = ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'GROUP BY'];
foreach ($aggregateFunctions as $func) {
if (str_contains($sql, $func)) {
return true;
}
}
return false;
}
/**
* Check if query is complex (multiple joins, subqueries)
*/
private function isComplexQuery(string $sql): bool
{
$complexity = 0;
// Count joins
$complexity += substr_count($sql, 'JOIN');
// Count subqueries
$complexity += substr_count($sql, '(SELECT');
// Count UNION
$complexity += substr_count($sql, 'UNION');
return $complexity >= 3;
}
/**
* Record cache hit for statistics
*/
private function recordCacheHit(string $sql): void
{
$queryType = $this->getQueryType($sql);
if (! isset($this->queryStats[$queryType])) {
$this->queryStats[$queryType] = ['hits' => 0, 'misses' => 0];
}
$this->queryStats[$queryType]['hits']++;
}
/**
* Record cache miss for statistics
*/
private function recordCacheMiss(string $sql): void
{
$queryType = $this->getQueryType($sql);
if (! isset($this->queryStats[$queryType])) {
$this->queryStats[$queryType] = ['hits' => 0, 'misses' => 0];
}
$this->queryStats[$queryType]['misses']++;
}
/**
* Get query type for statistics
*/
private function getQueryType(string $sql): string
{
$normalized = $this->normalizeSql($sql);
if (str_starts_with($normalized, 'SELECT')) {
return 'SELECT';
}
if (str_starts_with($normalized, 'INSERT')) {
return 'INSERT';
}
if (str_starts_with($normalized, 'UPDATE')) {
return 'UPDATE';
}
if (str_starts_with($normalized, 'DELETE')) {
return 'DELETE';
}
return 'OTHER';
}
/**
* Get table name from entity class
*/
private function getTableNameFromEntity(string $entityClass): string
{
// Simple conversion - in real implementation would use metadata
$className = basename(str_replace('\\', '/', $entityClass));
return strtolower($className);
}
/**
* Invalidate cache entries by pattern
*/
private function invalidateByPattern(string $pattern): void
{
// This would need Redis-specific implementation
// For now it's a placeholder
}
}

View File

@@ -47,6 +47,7 @@ final readonly class QueryCacheKey
];
$serialized = serialize($keyData);
return 'query_cache:' . hash('sha256', $serialized);
}
@@ -54,6 +55,7 @@ final readonly class QueryCacheKey
{
// Entferne überflüssige Zeichen und normalisiere
$normalized = preg_replace('/\s+/', ' ', trim($sql));
return strtolower($normalized);
}

View File

@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Cache\Driver\RedisCache;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\Events\EntityCreatedEvent;
use App\Framework\Database\Events\EntityDeletedEvent;
use App\Framework\Database\Events\EntityUpdatedEvent;
use App\Framework\Database\Serialization\EntitySerializer;
/**
* Redis-based second-level cache implementation
*/
final class RedisSecondLevelCache implements SecondLevelCacheInterface
{
/** @var array<string, CacheRegion> */
private array $regions = [];
/** @var array<string, int> Cache statistics */
private array $stats = [
'hits' => 0,
'misses' => 0,
'puts' => 0,
'evictions' => 0,
'query_hits' => 0,
'query_misses' => 0,
];
public function __construct(
private readonly RedisCache $cache,
private readonly EntitySerializer $serializer,
private readonly EventDispatcher $eventDispatcher,
private readonly bool $enabled = true
) {
$this->initializeDefaultRegions();
$this->registerEventListeners();
}
public function putEntity(string $entityClass, mixed $entityId, object $entity, ?Duration $ttl = null): bool
{
if (! $this->enabled) {
return false;
}
$region = $this->getRegionForEntity($entityClass);
if (! $region->enabled) {
return false;
}
$cacheKey = $region->getEntityKey($entityClass, $entityId);
$serializedEntity = $this->serializer->serialize($entity);
$effectiveTtl = $ttl ?? $region->defaultTtl;
$success = $this->cache->set($cacheKey, $serializedEntity, $effectiveTtl->toCacheSeconds());
if ($success) {
$this->stats['puts']++;
}
return $success;
}
public function getEntity(string $entityClass, mixed $entityId): ?object
{
if (! $this->enabled) {
return null;
}
$region = $this->getRegionForEntity($entityClass);
if (! $region->enabled) {
return null;
}
$cacheKey = $region->getEntityKey($entityClass, $entityId);
$cacheItem = $this->cache->get($cacheKey);
if ($cacheItem->isHit()) {
$this->stats['hits']++;
return $this->serializer->deserialize($cacheItem->getValue(), $entityClass);
}
$this->stats['misses']++;
return null;
}
public function hasEntity(string $entityClass, mixed $entityId): bool
{
if (! $this->enabled) {
return false;
}
$region = $this->getRegionForEntity($entityClass);
if (! $region->enabled) {
return false;
}
$cacheKey = $region->getEntityKey($entityClass, $entityId);
return $this->cache->has($cacheKey);
}
public function evictEntity(string $entityClass, mixed $entityId): bool
{
if (! $this->enabled) {
return false;
}
$region = $this->getRegionForEntity($entityClass);
$cacheKey = $region->getEntityKey($entityClass, $entityId);
$success = $this->cache->forget($cacheKey);
if ($success) {
$this->stats['evictions']++;
}
return $success;
}
public function putQueryResult(QueryCacheKey $key, array $result, ?Duration $ttl = null): bool
{
if (! $this->enabled) {
return false;
}
$cacheKey = $key->toString();
$serializedResult = serialize($result);
$effectiveTtl = $ttl ?? Duration::fromMinutes(15); // Default query cache TTL
return $this->cache->set($cacheKey, $serializedResult, $effectiveTtl->toCacheSeconds());
}
public function getQueryResult(QueryCacheKey $key): ?array
{
if (! $this->enabled) {
return null;
}
$cacheKey = $key->toString();
$cacheItem = $this->cache->get($cacheKey);
if ($cacheItem->isHit()) {
$this->stats['query_hits']++;
return unserialize($cacheItem->getValue());
}
$this->stats['query_misses']++;
return null;
}
public function hasQueryResult(QueryCacheKey $key): bool
{
if (! $this->enabled) {
return false;
}
return $this->cache->has($key->toString());
}
public function evictQueryResult(QueryCacheKey $key): bool
{
if (! $this->enabled) {
return false;
}
return $this->cache->forget($key->toString());
}
public function putCollection(string $entityClass, array $entities, string $cacheKey, ?Duration $ttl = null): bool
{
if (! $this->enabled) {
return false;
}
$region = $this->getRegionForEntity($entityClass);
if (! $region->enabled) {
return false;
}
$collectionKey = $region->getCollectionKey($entityClass, $cacheKey);
$serializedEntities = array_map([$this->serializer, 'serialize'], $entities);
$effectiveTtl = $ttl ?? $region->defaultTtl;
return $this->cache->set($collectionKey, serialize($serializedEntities), $effectiveTtl->toCacheSeconds());
}
public function getCollection(string $entityClass, string $cacheKey): ?array
{
if (! $this->enabled) {
return null;
}
$region = $this->getRegionForEntity($entityClass);
if (! $region->enabled) {
return null;
}
$collectionKey = $region->getCollectionKey($entityClass, $cacheKey);
$cacheItem = $this->cache->get($collectionKey);
if ($cacheItem->isHit()) {
$serializedEntities = unserialize($cacheItem->getValue());
return array_map(fn ($data) => $this->serializer->deserialize($data, $entityClass), $serializedEntities);
}
return null;
}
public function hasCollection(string $entityClass, string $cacheKey): bool
{
if (! $this->enabled) {
return false;
}
$region = $this->getRegionForEntity($entityClass);
if (! $region->enabled) {
return false;
}
$collectionKey = $region->getCollectionKey($entityClass, $cacheKey);
return $this->cache->has($collectionKey);
}
public function evictCollection(string $entityClass, string $cacheKey): bool
{
if (! $this->enabled) {
return false;
}
$region = $this->getRegionForEntity($entityClass);
$collectionKey = $region->getCollectionKey($entityClass, $cacheKey);
return $this->cache->forget($collectionKey);
}
public function evictEntityClass(string $entityClass): int
{
if (! $this->enabled) {
return 0;
}
$region = $this->getRegionForEntity($entityClass);
$pattern = $region->getEntityClassPattern($entityClass);
// Redis-specific pattern invalidation
return $this->invalidatePattern($pattern);
}
public function clear(): bool
{
if (! $this->enabled) {
return false;
}
return $this->cache->clear();
}
public function getStats(): array
{
return $this->stats;
}
public function getRegionStats(): array
{
$regionStats = [];
foreach ($this->regions as $name => $region) {
$regionStats[$name] = [
'enabled' => $region->enabled,
'default_ttl' => $region->defaultTtl->toSeconds(),
'max_size' => $region->maxSize,
];
}
return $regionStats;
}
/**
* Register cache regions for different entity types
*/
public function registerRegion(string $entityClass, CacheRegion $region): void
{
$this->regions[$entityClass] = $region;
}
/**
* Get cache region for entity class
*/
private function getRegionForEntity(string $entityClass): CacheRegion
{
return $this->regions[$entityClass] ?? $this->regions['default'];
}
/**
* Initialize default cache regions
*/
private function initializeDefaultRegions(): void
{
$this->regions = [
'default' => CacheRegion::slowChanging('default'),
'User' => CacheRegion::fastChanging('users'),
'Session' => CacheRegion::fastChanging('sessions'),
'Config' => CacheRegion::readOnly('config'),
];
}
/**
* Register event listeners for cache invalidation
*/
private function registerEventListeners(): void
{
$this->eventDispatcher->listen(EntityCreatedEvent::class, [$this, 'onEntityCreated']);
$this->eventDispatcher->listen(EntityUpdatedEvent::class, [$this, 'onEntityUpdated']);
$this->eventDispatcher->listen(EntityDeletedEvent::class, [$this, 'onEntityDeleted']);
}
/**
* Handle entity creation event
*/
public function onEntityCreated(EntityCreatedEvent $event): void
{
// Invalidate collection caches for this entity type
$this->invalidateCollectionCaches($event->entityClass);
}
/**
* Handle entity update event
*/
public function onEntityUpdated(EntityUpdatedEvent $event): void
{
// Evict the specific entity from cache
$this->evictEntity($event->entityClass, $event->entityId);
// Invalidate collection caches for this entity type
$this->invalidateCollectionCaches($event->entityClass);
}
/**
* Handle entity deletion event
*/
public function onEntityDeleted(EntityDeletedEvent $event): void
{
// Evict the specific entity from cache
$this->evictEntity($event->entityClass, $event->entityId);
// Invalidate collection caches for this entity type
$this->invalidateCollectionCaches($event->entityClass);
}
/**
* Invalidate collection caches for entity class
*/
private function invalidateCollectionCaches(string $entityClass): void
{
$region = $this->getRegionForEntity($entityClass);
$pattern = $region->getKeyPrefix() . 'collection:' . $this->getShortClassName($entityClass) . ':*';
$this->invalidatePattern($pattern);
}
/**
* Invalidate cache entries by pattern
*/
private function invalidatePattern(string $pattern): int
{
// This would need Redis-specific implementation
// For now, we'll use a placeholder
return 0;
}
private function getShortClassName(string $entityClass): string
{
$parts = explode('\\', $entityClass);
return end($parts);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Cache\Driver\RedisCache;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\Serialization\EntitySerializer;
use App\Framework\Database\TypeConverter;
use App\Framework\Redis\RedisConnectionInterface;
/**
* Factory for creating second-level cache components
*/
final class SecondLevelCacheFactory
{
public static function createCache(
RedisConnectionInterface $redisConnection,
EventDispatcher $eventDispatcher,
MetadataRegistry $metadataRegistry,
TypeConverter $typeConverter,
bool $enabled = true
): SecondLevelCacheInterface {
// Create Redis cache driver
$redisCache = new RedisCache($redisConnection, 'l2cache:');
// Create entity serializer
$entitySerializer = new EntitySerializer($metadataRegistry, $typeConverter);
// Create the second-level cache
return new RedisSecondLevelCache(
$redisCache,
$entitySerializer,
$eventDispatcher,
$enabled
);
}
public static function createCacheManager(
SecondLevelCacheInterface $cache,
\App\Framework\Database\IdentityMap $identityMap,
MetadataRegistry $metadataRegistry,
bool $enabled = true
): EntityCacheManager {
return new EntityCacheManager(
$cache,
$identityMap,
$metadataRegistry,
$enabled
);
}
/**
* Create complete second-level cache stack
*/
public static function create(
RedisConnectionInterface $redisConnection,
EventDispatcher $eventDispatcher,
MetadataRegistry $metadataRegistry,
TypeConverter $typeConverter,
\App\Framework\Database\IdentityMap $identityMap,
bool $enabled = true
): EntityCacheManager {
$cache = self::createCache(
$redisConnection,
$eventDispatcher,
$metadataRegistry,
$typeConverter,
$enabled
);
return self::createCacheManager(
$cache,
$identityMap,
$metadataRegistry,
$enabled
);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\IdentityMap;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\TypeConverter;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Redis\RedisConnectionInterface;
final readonly class SecondLevelCacheInitializer
{
#[Initializer]
public function __invoke(Container $container): EntityCacheManager
{
$databaseConfig = $container->get(DatabaseConfig::class);
// Check if second-level cache is enabled
$cacheEnabled = $databaseConfig->cacheConfig->enabled;
if (! $cacheEnabled) {
// Return disabled cache manager
return new EntityCacheManager(
new NullSecondLevelCache(),
$container->get(IdentityMap::class),
$container->get(MetadataRegistry::class),
false
);
}
// Get required dependencies
$redisConnection = $container->get(RedisConnectionInterface::class);
$eventDispatcher = $container->get(EventDispatcher::class);
$metadataRegistry = $container->get(MetadataRegistry::class);
$typeConverter = $container->get(TypeConverter::class);
$identityMap = $container->get(IdentityMap::class);
// Create the cache manager
return SecondLevelCacheFactory::create(
$redisConnection,
$eventDispatcher,
$metadataRegistry,
$typeConverter,
$identityMap,
$cacheEnabled
);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache;
use App\Framework\Core\ValueObjects\Duration;
/**
* Second-Level Cache for entity data caching
*/
interface SecondLevelCacheInterface
{
/**
* Cache an entity by its class and ID
*/
public function putEntity(string $entityClass, mixed $entityId, object $entity, ?Duration $ttl = null): bool;
/**
* Get an entity from cache by class and ID
*/
public function getEntity(string $entityClass, mixed $entityId): ?object;
/**
* Check if entity exists in cache
*/
public function hasEntity(string $entityClass, mixed $entityId): bool;
/**
* Remove entity from cache
*/
public function evictEntity(string $entityClass, mixed $entityId): bool;
/**
* Cache query results
*/
public function putQueryResult(QueryCacheKey $key, array $result, ?Duration $ttl = null): bool;
/**
* Get query results from cache
*/
public function getQueryResult(QueryCacheKey $key): ?array;
/**
* Check if query result exists in cache
*/
public function hasQueryResult(QueryCacheKey $key): bool;
/**
* Remove query result from cache
*/
public function evictQueryResult(QueryCacheKey $key): bool;
/**
* Cache collection results
*/
public function putCollection(string $entityClass, array $entities, string $cacheKey, ?Duration $ttl = null): bool;
/**
* Get collection from cache
*/
public function getCollection(string $entityClass, string $cacheKey): ?array;
/**
* Check if collection exists in cache
*/
public function hasCollection(string $entityClass, string $cacheKey): bool;
/**
* Remove collection from cache
*/
public function evictCollection(string $entityClass, string $cacheKey): bool;
/**
* Invalidate all cache entries for a specific entity class
*/
public function evictEntityClass(string $entityClass): int;
/**
* Clear all second-level cache
*/
public function clear(): bool;
/**
* Get cache statistics
*/
public function getStats(): array;
/**
* Get cache regions statistics
*/
public function getRegionStats(): array;
}

View File

@@ -10,7 +10,9 @@ namespace App\Framework\Database\Cache;
final class SimpleCacheStrategy implements CacheStrategy
{
private array $cache = [];
private array $expiry = [];
private array $stats = [
'hits' => 0,
'misses' => 0,
@@ -21,7 +23,8 @@ final class SimpleCacheStrategy implements CacheStrategy
public function __construct(
private readonly string $keyPrefix = 'db_query:'
) {}
) {
}
public function set(QueryCacheKey $key, array $value, int $ttlSeconds): bool
{
@@ -42,15 +45,18 @@ final class SimpleCacheStrategy implements CacheStrategy
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;
}
@@ -86,6 +92,7 @@ final class SimpleCacheStrategy implements CacheStrategy
}
$this->stats['invalidations'] += $deleted;
return $deleted;
}
@@ -103,7 +110,7 @@ final class SimpleCacheStrategy implements CacheStrategy
'size' => count($this->cache),
'memory_usage' => $this->estimateMemoryUsage(),
'hit_ratio' => $this->calculateHitRatio(),
'cache_type' => 'SimpleCacheStrategy'
'cache_type' => 'SimpleCacheStrategy',
]);
}
@@ -115,6 +122,7 @@ final class SimpleCacheStrategy implements CacheStrategy
private function calculateHitRatio(): float
{
$total = $this->stats['hits'] + $this->stats['misses'];
return $total > 0 ? ($this->stats['hits'] / $total) : 0.0;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
/**
* Comprehensive cache metrics snapshot
*/
final readonly class CacheMetricsSnapshot
{
public function __construct(
public CacheOverviewMetrics $overview,
public CachePerformanceMetrics $performance,
public array $entities,
public QueryMetrics $queries,
public array $regions,
public array $recommendations
) {
}
public function toArray(): array
{
return [
'overview' => $this->overview->toArray(),
'performance' => $this->performance->toArray(),
'entities' => array_map(fn ($metric) => $metric->toArray(), $this->entities),
'queries' => $this->queries->toArray(),
'regions' => array_map(fn ($metric) => $metric->toArray(), $this->regions),
'recommendations' => array_map(fn ($rec) => $rec->toArray(), $this->recommendations),
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Cache overview metrics value object
*/
final readonly class CacheOverviewMetrics
{
public function __construct(
public bool $enabled,
public int $totalRequests,
public int $totalHits,
public int $totalMisses,
public Percentage $hitRatio,
public Duration $uptime,
public float $requestsPerSecond
) {
}
public function isPerformingWell(): bool
{
return $this->hitRatio->isAbove(Percentage::from(70.0));
}
public function needsAttention(): bool
{
return $this->hitRatio->isBelow(Percentage::from(30.0));
}
public function toArray(): array
{
return [
'enabled' => $this->enabled,
'total_requests' => $this->totalRequests,
'total_hits' => $this->totalHits,
'total_misses' => $this->totalMisses,
'hit_ratio' => $this->hitRatio->format(),
'uptime_seconds' => $this->uptime->toSeconds(),
'requests_per_second' => round($this->requestsPerSecond, 2),
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Cache performance metrics value object
*/
final readonly class CachePerformanceMetrics
{
public function __construct(
public EntityCacheMetrics $entityCache,
public QueryCacheMetrics $queryCache,
public MemoryUsageMetrics $memory
) {
}
public function getOverallHitRatio(): Percentage
{
$totalHits = $this->entityCache->hits + $this->queryCache->hits;
$totalMisses = $this->entityCache->misses + $this->queryCache->misses;
return ($totalHits + $totalMisses) > 0
? Percentage::fromRatio($totalHits, $totalHits + $totalMisses)
: Percentage::zero();
}
public function toArray(): array
{
return [
'entity_cache' => $this->entityCache->toArray(),
'query_cache' => $this->queryCache->toArray(),
'memory' => $this->memory->toArray(),
'overall_hit_ratio' => $this->getOverallHitRatio()->format(),
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
/**
* Cache optimization recommendation value object
*/
final readonly class CacheRecommendation
{
public function __construct(
public RecommendationType $type,
public RecommendationCategory $category,
public string $message,
public RecommendationImpact $impact
) {
}
public function isActionable(): bool
{
return $this->impact === RecommendationImpact::HIGH ||
$this->impact === RecommendationImpact::MEDIUM;
}
public function toArray(): array
{
return [
'type' => $this->type->value,
'category' => $this->category->value,
'message' => $this->message,
'impact' => $this->impact->value,
'is_actionable' => $this->isActionable(),
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Entity-specific cache metrics value object
*/
final readonly class EntityCacheMetrics
{
public function __construct(
public int $hits,
public int $misses,
public Percentage $hitRatio,
public int $puts,
public int $evictions
) {
}
public function getTotalRequests(): int
{
return $this->hits + $this->misses;
}
public function getEvictionRate(): Percentage
{
return $this->puts > 0
? Percentage::fromRatio($this->evictions, $this->puts)
: Percentage::zero();
}
public function toArray(): array
{
return [
'hits' => $this->hits,
'misses' => $this->misses,
'hit_ratio' => $this->hitRatio->format(),
'puts' => $this->puts,
'evictions' => $this->evictions,
'eviction_rate' => $this->getEvictionRate()->format(),
'total_requests' => $this->getTotalRequests(),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Individual entity metrics value object
*/
final readonly class EntityMetrics
{
public function __construct(
public int $hits,
public int $misses,
public Percentage $hitRatio,
public int $cacheSize,
public Duration $averageAccessTime
) {
}
public function getTotalAccess(): int
{
return $this->hits + $this->misses;
}
public function isPopular(): bool
{
return $this->getTotalAccess() > 100; // Arbitrary threshold
}
public function isEfficient(): bool
{
return $this->hitRatio->isAbove(Percentage::from(60.0));
}
public function toArray(): array
{
return [
'hits' => $this->hits,
'misses' => $this->misses,
'hit_ratio' => $this->hitRatio->format(),
'cache_size' => $this->cacheSize,
'average_access_time_ms' => $this->averageAccessTime->toMilliseconds(),
'total_access' => $this->getTotalAccess(),
'is_popular' => $this->isPopular(),
'is_efficient' => $this->isEfficient(),
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Memory usage metrics value object
*/
final readonly class MemoryUsageMetrics
{
public function __construct(
public Byte $current,
public Byte $peak,
public Byte $limit,
public Percentage $usagePercentage
) {
}
public function isNearLimit(): bool
{
return $this->usagePercentage->isAbove(Percentage::from(80.0));
}
public function isCritical(): bool
{
return $this->usagePercentage->isCritical(90.0);
}
public function getAvailable(): Byte
{
return $this->limit->isEmpty()
? Byte::zero()
: $this->limit->subtract($this->current);
}
public function toArray(): array
{
return [
'current' => $this->current->toHumanReadable(),
'current_bytes' => $this->current->toBytes(),
'peak' => $this->peak->toHumanReadable(),
'peak_bytes' => $this->peak->toBytes(),
'limit' => $this->limit->isEmpty() ? 'unlimited' : $this->limit->toHumanReadable(),
'limit_bytes' => $this->limit->toBytes(),
'usage_percentage' => $this->usagePercentage->format(),
'available' => $this->getAvailable()->toHumanReadable(),
'is_near_limit' => $this->isNearLimit(),
'is_critical' => $this->isCritical(),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Query cache metrics value object
*/
final readonly class QueryCacheMetrics
{
public function __construct(
public int $hits,
public int $misses,
public Percentage $hitRatio
) {
}
public function getTotalRequests(): int
{
return $this->hits + $this->misses;
}
public function toArray(): array
{
return [
'hits' => $this->hits,
'misses' => $this->misses,
'hit_ratio' => $this->hitRatio->format(),
'total_requests' => $this->getTotalRequests(),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
/**
* Query cache metrics value object
*/
final readonly class QueryMetrics
{
public function __construct(
public int $totalCachedQueries,
public array $topQueries,
public array $slowQueries
) {
}
public function hasPerformanceIssues(): bool
{
return count($this->slowQueries) > 5;
}
public function toArray(): array
{
return [
'total_cached_queries' => $this->totalCachedQueries,
'top_queries' => $this->topQueries,
'slow_queries' => $this->slowQueries,
'has_performance_issues' => $this->hasPerformanceIssues(),
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
/**
* Cache recommendation categories
*/
enum RecommendationCategory: string
{
case PERFORMANCE = 'performance';
case MEMORY = 'memory';
case CONFIGURATION = 'configuration';
case OPTIMIZATION = 'optimization';
case SECURITY = 'security';
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
/**
* Cache recommendation impact levels
*/
enum RecommendationImpact: string
{
case HIGH = 'high';
case MEDIUM = 'medium';
case LOW = 'low';
case POSITIVE = 'positive';
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
/**
* Cache recommendation types
*/
enum RecommendationType: string
{
case WARNING = 'warning';
case SUCCESS = 'success';
case INFO = 'info';
case CRITICAL = 'critical';
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Cache\ValueObjects;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
/**
* Cache region metrics value object
*/
final readonly class RegionMetrics
{
public function __construct(
public string $name,
public bool $enabled,
public Duration $defaultTtl,
public int $maxSize,
public Percentage $efficiencyScore
) {
}
public function isPerformingWell(): bool
{
return $this->efficiencyScore->isAbove(Percentage::from(70.0));
}
public function needsOptimization(): bool
{
return $this->efficiencyScore->isBelow(Percentage::from(50.0));
}
public function toArray(): array
{
return [
'name' => $this->name,
'enabled' => $this->enabled,
'default_ttl_seconds' => $this->defaultTtl->toSeconds(),
'max_size' => $this->maxSize,
'efficiency_score' => $this->efficiencyScore->format(),
'is_performing_well' => $this->isPerformingWell(),
'needs_optimization' => $this->needsOptimization(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\EntityManager;
use App\Framework\Database\Profiling\ProfilingDashboard;
final class ClearProfilingDataCommand
{
public function __construct(
private DatabaseManager $databaseManager,
private EntityManager $entityManager
) {
}
#[ConsoleCommand(name: 'db:profiling:clear', description: 'Clear all database profiling data and statistics')]
public function execute(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine("🧹 Clearing Database Profiling Data\n", ConsoleStyle::info());
// Check if profiling is enabled
if (! $this->databaseManager->isProfilingEnabled()) {
$output->writeLine("⚠️ Database profiling is not enabled", ConsoleStyle::warning());
return ExitCode::SUCCESS;
}
// Clear data from EntityManager
$this->entityManager->clearProfilingData();
$output->writeLine("✅ Cleared profiling data from EntityManager", ConsoleStyle::success());
// Create dashboard and clear all data
$dashboard = new ProfilingDashboard();
$this->databaseManager->registerWithProfilingDashboard($dashboard, 'main');
$dashboard->clearAllProfilingData();
$output->writeLine("✅ Cleared profiling data from all registered connections", ConsoleStyle::success());
$output->writeLine("\n🎉 All profiling data has been cleared!", ConsoleStyle::success());
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ExitCode;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Driver\Optimization\DatabaseOptimizer;
use App\Framework\Database\Driver\Optimization\MySQLOptimizer;
use App\Framework\Database\Driver\Optimization\PostgreSQLOptimizer;
use App\Framework\Database\Driver\Optimization\SQLiteOptimizer;
/**
* Command to optimize database tables and perform maintenance operations
*/
final readonly class DatabaseOptimizeCommand
{
public function __construct(
private DatabaseManager $databaseManager
) {
}
/**
* Optimize database tables
*
* @param string $connection The database connection to use
* @param string|null $table The table to optimize, or null for all tables
* @return ExitCode
*/
#[ConsoleCommand('db:optimize', 'Optimize database tables')]
public function optimize(string $connection = 'default', ?string $table = null): ExitCode
{
try {
$optimizer = $this->getOptimizer($connection);
echo "Optimizing tables...\n";
$results = $optimizer->optimizeTables($table);
$this->displayResults($results);
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
return ExitCode::SOFTWARE_ERROR;
}
}
/**
* Analyze database tables
*
* @param string $connection The database connection to use
* @param string|null $table The table to analyze, or null for all tables
* @return ExitCode
*/
#[ConsoleCommand('db:analyze', 'Analyze database tables')]
public function analyze(string $connection = 'default', ?string $table = null): ExitCode
{
try {
$optimizer = $this->getOptimizer($connection);
echo "Analyzing tables...\n";
$results = $optimizer->analyzeTables($table);
$this->displayResults($results);
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
return ExitCode::SOFTWARE_ERROR;
}
}
/**
* Check database tables
*
* @param string $connection The database connection to use
* @param string|null $table The table to check, or null for all tables
* @param bool $extended Whether to perform an extended check
* @return ExitCode
*/
#[ConsoleCommand('db:check', 'Check database tables for errors')]
public function check(string $connection = 'default', ?string $table = null, bool $extended = false): ExitCode
{
try {
$optimizer = $this->getOptimizer($connection);
echo "Checking tables...\n";
$results = $optimizer->checkTables($table, $extended);
$this->displayResults($results);
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
return ExitCode::SOFTWARE_ERROR;
}
}
/**
* Show table status
*
* @param string $connection The database connection to use
* @param string|null $table The table to show status for, or null for all tables
* @return ExitCode
*/
#[ConsoleCommand('db:status', 'Show database table status')]
public function status(string $connection = 'default', ?string $table = null): ExitCode
{
try {
$optimizer = $this->getOptimizer($connection);
echo "Table status:\n";
$results = $optimizer->getTableStatus($table);
foreach ($results as $tableName => $status) {
echo "\nTable: {$tableName}\n";
echo str_repeat('-', 80) . "\n";
// Format the status information
$this->displayTableStatus($status);
}
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
return ExitCode::SOFTWARE_ERROR;
}
}
/**
* Show index statistics
*
* @param string $connection The database connection to use
* @param string|null $table The table to show index statistics for, or null for all tables
* @return ExitCode
*/
#[ConsoleCommand('db:indexes', 'Show database index statistics')]
public function indexes(string $connection = 'default', ?string $table = null): ExitCode
{
try {
$optimizer = $this->getOptimizer($connection);
echo "Index statistics:\n";
$results = $optimizer->getIndexStatistics($table);
foreach ($results as $tableName => $indexes) {
echo "\nTable: {$tableName}\n";
echo str_repeat('-', 80) . "\n";
if (empty($indexes)) {
echo "No indexes found.\n";
continue;
}
foreach ($indexes as $indexName => $indexInfo) {
echo " Index: {$indexName}\n";
// Format the index information
$this->displayIndexInfo($indexInfo);
echo "\n";
}
}
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
return ExitCode::SOFTWARE_ERROR;
}
}
/**
* Get the appropriate optimizer for the database connection
*
* @param string $connection The database connection name
* @return DatabaseOptimizer The database optimizer
* @throws \RuntimeException If the database type is not supported
*/
private function getOptimizer(string $connection): DatabaseOptimizer
{
$conn = $this->databaseManager->getConnection($connection);
$driver = $conn->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
return match ($driver) {
'mysql' => new MySQLOptimizer($conn),
'pgsql' => new PostgreSQLOptimizer($conn),
'sqlite' => new SQLiteOptimizer($conn),
default => throw new \RuntimeException("Unsupported database driver: {$driver}")
};
}
/**
* Display the results of an operation
*
* @param array<string, string> $results The results to display
*/
private function displayResults(array $results): void
{
foreach ($results as $name => $result) {
echo " {$name}: {$result}\n";
}
}
/**
* Display table status information
*
* @param array<string, mixed> $status The table status information
*/
private function displayTableStatus(array $status): void
{
// Handle error case
if (isset($status['error'])) {
echo " Error: {$status['error']}\n";
return;
}
// Display basic information
if (isset($status['row_count'])) {
echo " Rows: " . number_format($status['row_count']) . "\n";
}
if (isset($status['columns'])) {
echo " Columns: {$status['columns']}\n";
}
// Display size information
if (isset($status['total_size'])) {
echo " Total size: {$status['total_size']}\n";
} elseif (isset($status['Data_length']) && isset($status['Index_length'])) {
$totalSize = $status['Data_length'] + $status['Index_length'];
echo " Total size: " . $this->formatBytes($totalSize) . "\n";
echo " Data size: " . $this->formatBytes($status['Data_length']) . "\n";
echo " Index size: " . $this->formatBytes($status['Index_length']) . "\n";
}
// Display engine information for MySQL
if (isset($status['Engine'])) {
echo " Engine: {$status['Engine']}\n";
}
// Display row format for MySQL
if (isset($status['Row_format'])) {
echo " Row format: {$status['Row_format']}\n";
}
// Display collation for MySQL
if (isset($status['Collation'])) {
echo " Collation: {$status['Collation']}\n";
}
// Display index count
if (isset($status['indexes']) && is_array($status['indexes'])) {
echo " Indexes: " . count($status['indexes']) . "\n";
}
// Display foreign key count
if (isset($status['foreign_keys']) && is_array($status['foreign_keys'])) {
echo " Foreign keys: " . count($status['foreign_keys']) . "\n";
}
// Display last update time for MySQL
if (isset($status['Update_time']) && $status['Update_time']) {
echo " Last updated: {$status['Update_time']}\n";
}
// Display auto increment value for MySQL
if (isset($status['Auto_increment']) && $status['Auto_increment']) {
echo " Auto increment: " . number_format($status['Auto_increment']) . "\n";
}
// Display PostgreSQL-specific information
if (isset($status['dead_rows']) && isset($status['row_count']) && $status['row_count'] > 0) {
$deadRowPercentage = round(($status['dead_rows'] / $status['row_count']) * 100, 2);
echo " Dead rows: {$status['dead_rows']} ({$deadRowPercentage}%)\n";
}
if (isset($status['last_vacuum'])) {
echo " Last vacuum: {$status['last_vacuum']}\n";
}
if (isset($status['last_analyze'])) {
echo " Last analyze: {$status['last_analyze']}\n";
}
}
/**
* Display index information
*
* @param array<string, mixed> $indexInfo The index information
*/
private function displayIndexInfo(array $indexInfo): void
{
// Handle error case
if (isset($indexInfo['error'])) {
echo " Error: {$indexInfo['error']}\n";
return;
}
// Display index type
if (isset($indexInfo['type'])) {
echo " Type: {$indexInfo['type']}\n";
} elseif (isset($indexInfo['index_type'])) {
echo " Type: {$indexInfo['index_type']}\n";
}
// Display uniqueness
if (isset($indexInfo['unique'])) {
echo " Unique: " . ($indexInfo['unique'] ? 'Yes' : 'No') . "\n";
} elseif (isset($indexInfo['is_unique'])) {
echo " Unique: " . ($indexInfo['is_unique'] ? 'Yes' : 'No') . "\n";
} elseif (isset($indexInfo['Non_unique'])) {
echo " Unique: " . ($indexInfo['Non_unique'] == 0 ? 'Yes' : 'No') . "\n";
}
// Display primary key status
if (isset($indexInfo['is_primary'])) {
echo " Primary key: " . ($indexInfo['is_primary'] ? 'Yes' : 'No') . "\n";
}
// Display columns
if (isset($indexInfo['columns']) && is_array($indexInfo['columns'])) {
echo " Columns: " . implode(', ', $indexInfo['columns']) . "\n";
} elseif (isset($indexInfo['Column_name'])) {
echo " Column: {$indexInfo['Column_name']}\n";
}
// Display cardinality
if (isset($indexInfo['cardinality'])) {
echo " Cardinality: " . number_format($indexInfo['cardinality']) . "\n";
} elseif (isset($indexInfo['Cardinality'])) {
echo " Cardinality: " . number_format($indexInfo['Cardinality']) . "\n";
}
// Display index size
if (isset($indexInfo['index_size'])) {
echo " Size: {$indexInfo['index_size']}\n";
}
// Display PostgreSQL-specific information
if (isset($indexInfo['index_scans'])) {
echo " Scans: " . number_format($indexInfo['index_scans']) . "\n";
}
if (isset($indexInfo['tuples_read']) && isset($indexInfo['tuples_fetched'])) {
echo " Tuples read: " . number_format($indexInfo['tuples_read']) . "\n";
echo " Tuples fetched: " . number_format($indexInfo['tuples_fetched']) . "\n";
}
}
/**
* Format bytes to a human-readable string
*
* @param int $bytes The number of bytes
* @param int $precision The number of decimal places
* @return string The formatted string
*/
private function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\Profiling\ProfilingDashboard;
final class ExportProfilingDataCommand
{
public function __construct(
private DatabaseManager $databaseManager
) {
}
#[ConsoleCommand(name: 'db:profiling:export', description: 'Export database profiling data to various formats (json, html)')]
public function execute(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
// Check if profiling is enabled
if (! $this->databaseManager->isProfilingEnabled()) {
$output->writeLine("⚠️ Database profiling is not enabled", ConsoleStyle::warning());
return ExitCode::SUCCESS;
}
// Get format parameter (default: json)
$format = $input->getOption('format', 'json');
$connectionName = $input->getOption('connection');
if (! in_array($format, ['json', 'html'])) {
$output->writeLine("❌ Invalid format '{$format}'. Supported formats: json, html", ConsoleStyle::error());
return ExitCode::FAILURE;
}
// Create dashboard and register connection
$dashboard = new ProfilingDashboard();
$this->databaseManager->registerWithProfilingDashboard($dashboard, $connectionName ?? 'main');
try {
$exportData = $dashboard->export($format, $connectionName);
if ($format === 'html') {
$output->writeLine("📄 Database Profiling Report (HTML Format)\n", ConsoleStyle::info());
} else {
$output->writeLine("📊 Database Profiling Data (JSON Format)\n", ConsoleStyle::info());
}
// Output the exported data
$output->write($exportData);
} catch (\Exception $e) {
$output->writeLine("❌ Export failed: " . $e->getMessage(), ConsoleStyle::error());
return ExitCode::FAILURE;
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
use App\Framework\Database\DatabaseManager;
use App\Framework\Database\EntityManager;
use App\Framework\Database\Profiling\ProfilingDashboard;
final class ShowProfilingStatsCommand
{
public function __construct(
private DatabaseManager $databaseManager,
private EntityManager $entityManager
) {
}
#[ConsoleCommand(name: 'db:profiling:stats', description: 'Show database profiling statistics and reports')]
public function execute(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine("🔍 Database Profiling Statistics\n", ConsoleStyle::info());
// Check if profiling is enabled
if (! $this->databaseManager->isProfilingEnabled()) {
$output->writeLine("⚠️ Database profiling is not enabled", ConsoleStyle::warning());
$output->writeLine("Enable it in your configuration to see detailed statistics\n");
return ExitCode::SUCCESS;
}
// Create profiling dashboard
$dashboard = new ProfilingDashboard();
// Register database manager with dashboard
$this->databaseManager->registerWithProfilingDashboard($dashboard, 'main');
// Get overview
$overview = $dashboard->getOverview();
$output->writeLine("📊 Overview:");
$output->writeLine(" Total Connections: " . $overview['total_connections']);
$output->writeLine(" Total Queries: " . $overview['global_stats']['total_queries']);
$output->writeLine(" Slow Queries: " . $overview['global_stats']['total_slow_queries']);
$output->writeLine(" Slow Query %: " . round($overview['global_stats']['slow_query_percentage'], 2) . "%");
$output->writeLine(" Avg Execution Time: " . round($overview['global_stats']['average_execution_time_ms'], 2) . " ms\n");
// Show connection details
foreach ($overview['connections'] as $name => $conn) {
$statusColor = match($conn['performance_assessment']) {
'excellent', 'good' => ConsoleStyle::success(),
'fair', 'moderate' => ConsoleStyle::warning(),
default => ConsoleStyle::error()
};
$output->writeLine("🔗 Connection: {$name}", ConsoleStyle::info());
$output->writeLine(" Status: " . ucfirst($conn['performance_assessment']), $statusColor);
$output->writeLine(" Queries: {$conn['queries_count']}");
$output->writeLine(" Slow Queries: {$conn['slow_queries_count']} ({$conn['slow_query_percentage']})");
$output->writeLine(" Avg Time: " . round($conn['average_execution_time_ms'], 2) . " ms");
$output->writeLine(" Profiling Active: " . ($conn['enabled'] ? '✅' : '❌') . "\n");
}
// Show performance report
try {
$performanceReport = $dashboard->getPerformanceReport();
$metrics = $performanceReport->getOverallMetrics();
$output->writeLine("⚡ Performance Metrics:");
$output->writeLine(" Total Execution Time: " . round($metrics['total_execution_time_ms'], 2) . " ms");
$output->writeLine(" Average Query Time: " . round($metrics['average_execution_time_ms'], 2) . " ms\n");
$trends = $performanceReport->getPerformanceTrends();
foreach ($trends as $connectionName => $trend) {
$trendColor = match($trend['trend']) {
'excellent', 'improving' => ConsoleStyle::success(),
'stable' => ConsoleStyle::info(),
'declining' => ConsoleStyle::warning(),
'critical' => ConsoleStyle::error()
};
$output->writeLine("📈 {$connectionName} Trend: " . ucfirst($trend['trend']), $trendColor);
if (! empty($trend['recommendations'])) {
foreach ($trend['recommendations'] as $recommendation) {
$output->writeLine(" 💡 {$recommendation}", ConsoleStyle::dim());
}
}
}
} catch (\Exception $e) {
$output->writeLine("⚠️ Could not generate performance report: " . $e->getMessage(), ConsoleStyle::warning());
}
// Show slow query report if there are slow queries
try {
$slowQueryReport = $dashboard->getSlowQueryReport();
$slowQueries = $slowQueryReport->getTopSlowQueries(5);
if (! empty($slowQueries)) {
$output->writeLine("\n🐌 Top 5 Slow Queries:");
foreach ($slowQueries as $index => $entry) {
$profile = $entry['profile'];
$connection = $entry['connection'];
$output->writeLine(" " . ($index + 1) . ". [{$connection}] " . round($profile->executionTime->toMilliseconds(), 2) . " ms");
$output->writeLine(" " . substr($profile->sql, 0, 80) . "...", ConsoleStyle::dim());
}
}
} catch (\Exception $e) {
$output->writeLine("⚠️ Could not generate slow query report: " . $e->getMessage(), ConsoleStyle::warning());
}
// Show optimization suggestions
try {
$optimizationReport = $dashboard->getOptimizationReport();
$summary = $optimizationReport->getOptimizationSummary();
if ($summary['critical_issues'] > 0 || $summary['total_issues'] > 0) {
$output->writeLine("\n🔧 Optimization Summary:");
$output->writeLine(" Total Issues: " . $summary['total_issues']);
$output->writeLine(" Critical Issues: " . $summary['critical_issues']);
if (! empty($summary['top_recommendations'])) {
$output->writeLine("\n💡 Top Recommendations:");
$count = 0;
foreach ($summary['top_recommendations'] as $rec) {
if (++$count > 3) {
break;
}
$output->writeLine("" . $rec['text'] . " (affects " . $rec['count'] . " connection(s))", ConsoleStyle::info());
}
}
} else {
$output->writeLine("\n✅ No critical performance issues detected!", ConsoleStyle::success());
}
} catch (\Exception $e) {
$output->writeLine("⚠️ Could not generate optimization report: " . $e->getMessage(), ConsoleStyle::warning());
}
$output->writeLine("\n💡 Tip: Use 'db:profiling:clear' to clear profiling data", ConsoleStyle::dim());
$output->writeLine("💡 Tip: Use 'db:profiling:export --format=html > report.html' to export detailed reports", ConsoleStyle::dim());
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
use App\Framework\Core\ValueObjects\Duration;
/**
* Configuration for database second-level cache
*/
final readonly class CacheConfig
{
public Duration $defaultTtl;
public Duration $queryCacheTtl;
public function __construct(
public bool $enabled = false,
?Duration $defaultTtl = null, // 30 minutes
public int $maxSize = 10000,
public bool $queryCache = true,
?Duration $queryCacheTtl = null, // 15 minutes
public array $regions = [],
public array $enabledEntityClasses = [],
public array $disabledEntityClasses = []
) {
$this->defaultTtl = $defaultTtl ?? Duration::fromMinutes(30);
$this->queryCacheTtl = $queryCacheTtl ?? Duration::fromMinutes(15);
}
/**
* Create configuration for development environment
*/
public static function development(): self
{
return new self(
enabled: true,
defaultTtl: Duration::fromMinutes(15),
maxSize: 5000,
queryCache: true,
queryCacheTtl: Duration::fromMinutes(5),
regions: [
'User' => 'fast_changing',
'Config' => 'read_only',
'Translation' => 'read_only',
]
);
}
/**
* Create configuration for production environment
*/
public static function production(): self
{
return new self(
enabled: true,
defaultTtl: Duration::fromHours(1),
maxSize: 50000,
queryCache: true,
queryCacheTtl: Duration::fromMinutes(30),
regions: [
'User' => 'fast_changing',
'Session' => 'fast_changing',
'Config' => 'read_only',
'Translation' => 'read_only',
'Product' => 'slow_changing',
'Category' => 'slow_changing',
]
);
}
/**
* Create disabled cache configuration
*/
public static function disabled(): self
{
return new self(enabled: false);
}
/**
* Check if entity class has caching enabled
*/
public function isEntityCacheEnabled(string $entityClass): bool
{
if (! $this->enabled) {
return false;
}
// Check explicit disable list
if (in_array($entityClass, $this->disabledEntityClasses, true)) {
return false;
}
// If enabled list is specified, only cache those
if (! empty($this->enabledEntityClasses)) {
return in_array($entityClass, $this->enabledEntityClasses, true);
}
// Default to enabled if not in disabled list
return true;
}
/**
* Get cache region for entity class
*/
public function getRegionForEntity(string $entityClass): string
{
$shortName = $this->getShortClassName($entityClass);
return $this->regions[$shortName] ?? 'default';
}
private function getShortClassName(string $entityClass): string
{
$parts = explode('\\', $entityClass);
return end($parts);
}
}

View File

@@ -1,14 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Profiling\ProfilingConfig;
final class DatabaseConfig
{
public function __construct(
public DriverConfig $driverConfig,
public PoolConfig $poolConfig,
) {}
public ReadWriteConfig $readWriteConfig,
public CacheConfig $cacheConfig = new CacheConfig(),
public ProfilingConfig $profilingConfig = new ProfilingConfig()
) {
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Driver\DriverType;
use App\Framework\DI\Initializer;
final readonly class DatabaseConfigInitializer
{
public function __construct(
private Environment $env,
) {
}
#[Initializer]
public function __invoke(): DatabaseConfig
{
$driverConfig = new DriverConfig(
driverType: DriverType::from($this->env->getString(EnvKey::DB_DRIVER, 'mysql')),
host: $this->env->getString(EnvKey::DB_HOST, 'db'),
port: $this->env->getInt(EnvKey::DB_PORT, 3306),
database: $this->env->getRequired(EnvKey::DB_DATABASE),
username: $this->env->getRequired(EnvKey::DB_USERNAME),
password: $this->env->getRequired(EnvKey::DB_PASSWORD),
charset: $this->env->getString(EnvKey::DB_CHARSET, 'utf8mb4'),
);
$poolConfig = new PoolConfig(
enabled: true,
maxConnections: 10,
minConnections: 2
);
$readWriteConfig = new ReadWriteConfig(
enabled: false, // Disabled by default, can be enabled via env vars
readConnections: []
);
return new DatabaseConfig(
driverConfig: $driverConfig,
poolConfig: $poolConfig,
readWriteConfig: $readWriteConfig,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
/**
* Load balancing strategies for read replicas
*/
enum LoadBalancingStrategy: string
{
case ROUND_ROBIN = 'round_robin';
case RANDOM = 'random';
case LEAST_CONNECTIONS = 'least_connections';
case WEIGHTED = 'weighted';
case RESPONSE_TIME = 'response_time';
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
@@ -9,5 +10,13 @@ final class PoolConfig
public bool $enabled,
public int $maxConnections,
public int $minConnections,
) {}
public int $connectionTimeoutSeconds = 30,
public int $healthCheckIntervalSeconds = 60,
public int $maxIdleTimeSeconds = 300,
public int $maxRetries = 3,
public bool $enableHealthChecks = true,
public bool $enableWarmup = true,
public bool $enableMetrics = true,
) {
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Config;
use App\Framework\Database\Driver\DriverConfig;
final class ReadWriteConfig
{
/**
* @param array<DriverConfig> $readConnections
*/
public function __construct(
public bool $enabled,
public array $readConnections = [],
public LoadBalancingStrategy $loadBalancingStrategy = LoadBalancingStrategy::ROUND_ROBIN,
public bool $stickySessions = false,
public int $maxLagSeconds = 5,
public bool $failoverEnabled = true,
public int $healthCheckIntervalSeconds = 30,
public bool $readPreference = true,
public int $connectionRetryAttempts = 3
) {
}
public function hasReadReplicas(): bool
{
return ! empty($this->readConnections);
}
public function getReadReplicaCount(): int
{
return count($this->readConnections);
}
/**
* Get weight for specific read connection config
*/
public function getConnectionWeight(int $configIndex): int
{
return $this->readConnections[$configIndex]->weight ?? 100;
}
/**
* Get max connections for specific read connection config
*/
public function getMaxConnections(int $configIndex): int
{
return $this->readConnections[$configIndex]->maxConnections ?? 100;
}
/**
* Get all connection weights indexed by config position
*/
public function getAllWeights(): array
{
$weights = [];
foreach ($this->readConnections as $index => $config) {
$weights[$index] = $config->weight;
}
return $weights;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Async\AsyncService;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\DateTime\Timer;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
final readonly class ConnectionInitializer
{
public function __construct(
private ?AsyncService $asyncService = null,
private bool $enableAsync = true
) {
}
#[Initializer]
public function __invoke(Container $container): ConnectionInterface
{
// Check if EntityManagerInitializer already registered a connection
if ($container->has(DatabaseManager::class)) {
$connection = $container->get(DatabaseManager::class)->getConnection();
} else {
$databaseConfig = $container->get(DatabaseConfig::class);
$timer = $container->get(Timer::class);
// Create a simple database manager for connection only with minimal dependencies
$databaseManager = new DatabaseManager(
config: $databaseConfig,
timer: $timer,
migrationsPath: 'database/migrations'
);
$connection = $databaseManager->getConnection();
}
// Automatically wrap with AsyncAwareConnection if AsyncService is available
if ($this->enableAsync && $this->asyncService !== null) {
return new AsyncAwareConnection($connection, $this->asyncService);
}
return $connection;
}
}

View File

@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
/**
* Connection metadata for advanced pool management
*/
final class ConnectionMetadata
{
public function __construct(
public readonly string $id,
public readonly \DateTimeImmutable $createdAt,
public \DateTimeImmutable $lastUsedAt,
public \DateTimeImmutable $lastHealthCheckAt,
public bool $isHealthy = true,
public int $totalQueries = 0,
public int $failedQueries = 0,
public float $averageQueryTime = 0.0,
) {
}
public function recordQuery(float $executionTimeMs, bool $successful = true): void
{
$this->lastUsedAt = new \DateTimeImmutable();
$this->totalQueries++;
if (! $successful) {
$this->failedQueries++;
}
// Update average query time (simple moving average)
$this->averageQueryTime = ($this->averageQueryTime * ($this->totalQueries - 1) + $executionTimeMs) / $this->totalQueries;
}
public function updateHealthStatus(bool $isHealthy): void
{
$this->isHealthy = $isHealthy;
$this->lastHealthCheckAt = new \DateTimeImmutable();
}
public function isIdle(int $maxIdleTimeSeconds): bool
{
$idleTime = time() - $this->lastUsedAt->getTimestamp();
return $idleTime > $maxIdleTimeSeconds;
}
public function needsHealthCheck(int $healthCheckIntervalSeconds): bool
{
$timeSinceLastCheck = time() - $this->lastHealthCheckAt->getTimestamp();
return $timeSinceLastCheck > $healthCheckIntervalSeconds;
}
public function getStats(): array
{
return [
'id' => $this->id,
'created_at' => $this->createdAt->format('c'),
'last_used_at' => $this->lastUsedAt->format('c'),
'last_health_check_at' => $this->lastHealthCheckAt->format('c'),
'is_healthy' => $this->isHealthy,
'total_queries' => $this->totalQueries,
'failed_queries' => $this->failedQueries,
'failure_rate' => $this->totalQueries > 0 ? $this->failedQueries / $this->totalQueries : 0,
'average_query_time_ms' => $this->averageQueryTime,
'age_seconds' => time() - $this->createdAt->getTimestamp(),
'idle_time_seconds' => time() - $this->lastUsedAt->getTimestamp(),
];
}
}

View File

@@ -4,81 +4,386 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\Config\PoolConfig;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\DateTime\Timer;
final class ConnectionPool
{
private array $config;
/** @var array<string, ConnectionInterface> Connection storage by ID */
private array $connections = [];
/** @var \WeakMap<ConnectionInterface, ConnectionMetadata> Metadata mapped to connections */
private \WeakMap $connectionMetadata;
/** @var array<string, bool> Track which connections are in use */
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;
private \DateTimeImmutable $lastHealthCheck;
$this->initializeMinConnections();
private int $totalConnectionsCreated = 0;
private int $totalConnectionsDestroyed = 0;
public function __construct(
private readonly DriverConfig $driverConfig,
private readonly PoolConfig $poolConfig,
private readonly Timer $timer,
) {
$this->connectionMetadata = new \WeakMap();
$this->lastHealthCheck = new \DateTimeImmutable();
// Initialize min connections if warmup is enabled
if ($this->poolConfig->enableWarmup) {
$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);
}
// Perform periodic health checks
$this->performHealthChecksIfNeeded();
// Clean up idle connections
$this->cleanupIdleConnections();
// Lazy initialization - create min connections on first use
if (empty($this->connections) && ! $this->poolConfig->enableWarmup) {
$this->initializeMinConnections();
}
if ($this->currentConnections < $this->maxConnections) {
$connection = DatabaseFactory::createConnection($this->config);
$id = uniqid('conn_');
$this->connections[$id] = $connection;
// Try to find a healthy, available connection
$connection = $this->findAvailableHealthyConnection();
if ($connection !== null) {
return $connection;
}
// Create new connection if under limit
if ($this->currentConnections < $this->poolConfig->maxConnections) {
return $this->createNewConnection();
}
throw new DatabaseException('Maximum number of connections reached and no healthy connections available');
}
private function findAvailableHealthyConnection(): ?PooledConnection
{
foreach ($this->connections as $id => $connection) {
if (isset($this->inUse[$id])) {
continue; // Connection is in use
}
$metadata = $this->connectionMetadata[$connection] ?? null;
if ($metadata === null || ! $metadata->isHealthy) {
continue; // Connection is unhealthy
}
// Mark as in use and return
$this->inUse[$id] = true;
$this->currentConnections++;
return new PooledConnection($connection, $this, $id);
}
throw new DatabaseException('Maximum number of connections reached');
return null;
}
private function createNewConnection(): PooledConnection
{
$retries = 0;
$maxRetries = $this->poolConfig->maxRetries;
while ($retries < $maxRetries) {
try {
$connection = DatabaseFactory::createConnection($this->driverConfig);
$id = uniqid('conn_');
// Store connection
$this->connections[$id] = $connection;
$this->inUse[$id] = true;
$this->currentConnections++;
$this->totalConnectionsCreated++;
// Create metadata using WeakMap
$now = new \DateTimeImmutable();
$metadata = new ConnectionMetadata(
id: $id,
createdAt: $now,
lastUsedAt: $now,
lastHealthCheckAt: $now,
);
$this->connectionMetadata[$connection] = $metadata;
return new PooledConnection($connection, $this, $id);
} catch (\Exception $e) {
$retries++;
if ($retries >= $maxRetries) {
throw new DatabaseException("Failed to create connection after {$maxRetries} retries: " . $e->getMessage());
}
// Exponential backoff
$backoffMicroseconds = min(100000 * (2 ** $retries), 1000000); // Max 1 second
$this->timer->sleep(Duration::fromMicroseconds($backoffMicroseconds));
}
}
throw new DatabaseException('Unable to create new connection');
}
public function releaseConnection(string $id): void
{
unset($this->inUse[$id]);
// Update metadata if connection exists
if (isset($this->connections[$id])) {
$connection = $this->connections[$id];
$metadata = $this->connectionMetadata[$connection] ?? null;
if ($metadata !== null) {
$metadata->lastUsedAt = new \DateTimeImmutable();
}
}
}
public function closeConnection(string $id): void
{
unset($this->connections[$id], $this->inUse[$id]);
if (isset($this->connections[$id])) {
// The WeakMap will automatically clean up when connection is garbage collected
unset($this->connections[$id]);
$this->totalConnectionsDestroyed++;
}
unset($this->inUse[$id]);
$this->currentConnections--;
}
/**
* Record query execution for metrics
*/
public function recordQuery(string $connectionId, float $executionTimeMs, bool $successful = true): void
{
if (! $this->poolConfig->enableMetrics) {
return;
}
if (! isset($this->connections[$connectionId])) {
return;
}
$connection = $this->connections[$connectionId];
$metadata = $this->connectionMetadata[$connection] ?? null;
if ($metadata !== null) {
$metadata->recordQuery($executionTimeMs, $successful);
}
}
private function performHealthChecksIfNeeded(): void
{
if (! $this->poolConfig->enableHealthChecks) {
return;
}
$now = new \DateTimeImmutable();
$timeSinceLastCheck = $now->getTimestamp() - $this->lastHealthCheck->getTimestamp();
if ($timeSinceLastCheck < $this->poolConfig->healthCheckIntervalSeconds) {
return;
}
$this->lastHealthCheck = $now;
$this->performHealthChecks();
}
private function performHealthChecks(): void
{
foreach ($this->connections as $id => $connection) {
// Skip connections that are currently in use
if (isset($this->inUse[$id])) {
continue;
}
$metadata = $this->connectionMetadata[$connection] ?? null;
if ($metadata === null) {
continue;
}
// Check if health check is needed
if (! $metadata->needsHealthCheck($this->poolConfig->healthCheckIntervalSeconds)) {
continue;
}
$isHealthy = $this->checkConnectionHealth($connection);
$metadata->updateHealthStatus($isHealthy);
// Close unhealthy connections
if (! $isHealthy) {
$this->closeConnection($id);
}
}
}
private function checkConnectionHealth(ConnectionInterface $connection): bool
{
try {
// Simple health check query
$connection->queryScalar('SELECT 1');
return true;
} catch (\Exception) {
return false;
}
}
private function cleanupIdleConnections(): void
{
if ($this->currentConnections <= $this->poolConfig->minConnections) {
return; // Don't go below minimum connections
}
$connectionsToClose = [];
foreach ($this->connections as $id => $connection) {
// Skip connections that are currently in use
if (isset($this->inUse[$id])) {
continue;
}
$metadata = $this->connectionMetadata[$connection] ?? null;
if ($metadata === null) {
continue;
}
if ($metadata->isIdle($this->poolConfig->maxIdleTimeSeconds)) {
$connectionsToClose[] = $id;
}
}
// Close idle connections, but maintain minimum pool size
$maxToClose = $this->currentConnections - $this->poolConfig->minConnections;
$toClose = array_slice($connectionsToClose, 0, $maxToClose);
foreach ($toClose as $id) {
$this->closeConnection($id);
}
}
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,
$healthyConnections = 0;
$unhealthyConnections = 0;
$totalQueries = 0;
$totalFailedQueries = 0;
$connectionDetails = [];
// Collect detailed metrics from WeakMap
foreach ($this->connections as $id => $connection) {
$metadata = $this->connectionMetadata[$connection] ?? null;
if ($metadata !== null) {
if ($metadata->isHealthy) {
$healthyConnections++;
} else {
$unhealthyConnections++;
}
$totalQueries += $metadata->totalQueries;
$totalFailedQueries += $metadata->failedQueries;
if ($this->poolConfig->enableMetrics) {
$connectionDetails[$id] = $metadata->getStats();
}
}
}
$stats = [
'pool_status' => [
'total_connections' => $this->currentConnections,
'active_connections' => count($this->inUse),
'free_connections' => $this->currentConnections - count($this->inUse),
'healthy_connections' => $healthyConnections,
'unhealthy_connections' => $unhealthyConnections,
'max_connections' => $this->poolConfig->maxConnections,
'min_connections' => $this->poolConfig->minConnections,
],
'lifetime_stats' => [
'total_connections_created' => $this->totalConnectionsCreated,
'total_connections_destroyed' => $this->totalConnectionsDestroyed,
'total_queries' => $totalQueries,
'total_failed_queries' => $totalFailedQueries,
'failure_rate' => $totalQueries > 0 ? $totalFailedQueries / $totalQueries : 0,
],
'configuration' => [
'health_checks_enabled' => $this->poolConfig->enableHealthChecks,
'metrics_enabled' => $this->poolConfig->enableMetrics,
'warmup_enabled' => $this->poolConfig->enableWarmup,
'health_check_interval_seconds' => $this->poolConfig->healthCheckIntervalSeconds,
'max_idle_time_seconds' => $this->poolConfig->maxIdleTimeSeconds,
'connection_timeout_seconds' => $this->poolConfig->connectionTimeoutSeconds,
'max_retries' => $this->poolConfig->maxRetries,
],
];
if ($this->poolConfig->enableMetrics && ! empty($connectionDetails)) {
$stats['connection_details'] = $connectionDetails;
}
return $stats;
}
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++;
for ($i = 0; $i < $this->poolConfig->minConnections; $i++) {
try {
$connection = DatabaseFactory::createConnection($this->driverConfig);
$id = uniqid('conn_');
$this->connections[$id] = $connection;
$this->currentConnections++;
$this->totalConnectionsCreated++;
// Create metadata using WeakMap
$now = new \DateTimeImmutable();
$metadata = new ConnectionMetadata(
id: $id,
createdAt: $now,
lastUsedAt: $now,
lastHealthCheckAt: $now,
);
$this->connectionMetadata[$connection] = $metadata;
} catch (\Exception $e) {
// Silent failure during warmup - connection will be created on-demand
// This prevents bootstrap failures due to temporary database issues
}
}
}
/**
* Force health check on all connections
*/
public function forceHealthCheck(): void
{
$this->performHealthChecks();
}
/**
* Get connection metadata for debugging
*/
public function getConnectionMetadata(string $connectionId): ?array
{
if (! isset($this->connections[$connectionId])) {
return null;
}
$connection = $this->connections[$connectionId];
$metadata = $this->connectionMetadata[$connection] ?? null;
return $metadata?->getStats();
}
public function __destruct()
{
// WeakMap will automatically clean up metadata when connections are destroyed
$this->connections = [];
$this->inUse = [];
$this->currentConnections = 0;

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Contracts;
use App\Framework\Database\AsyncDatabaseAdapter;
/**
* Interface für Database-Implementierungen mit Async-Unterstützung
*/
interface AsyncCapable
{
/**
* Get async adapter for parallel operations
*/
public function async(): AsyncDatabaseAdapter;
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
/**
* Main criteria interface for building database queries
*/
interface Criteria
{
/**
* Add a criterion to this criteria
*/
public function add(Criterion $criterion): self;
/**
* Set the projection (SELECT clause)
*/
public function setProjection(Projection $projection): self;
/**
* Add an order by clause
*/
public function addOrder(Order $order): self;
/**
* Set maximum number of results
*/
public function setMaxResults(?int $maxResults): self;
/**
* Set first result offset
*/
public function setFirstResult(?int $firstResult): self;
/**
* Get all criteria (WHERE conditions)
*/
public function getCriteria(): array;
/**
* Get projection
*/
public function getProjection(): ?Projection;
/**
* Get orders
*/
public function getOrders(): array;
/**
* Get max results
*/
public function getMaxResults(): ?int;
/**
* Get first result offset
*/
public function getFirstResult(): ?int;
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
/**
* Query builder that converts Criteria to SQL
*/
final class CriteriaQuery
{
public function __construct(
public readonly Criteria $criteria,
public readonly string $tableName
) {
}
/**
* Build the complete SQL query
*/
public function toSql(): string
{
$sql = 'SELECT ' . $this->buildSelectClause();
$sql .= ' FROM ' . $this->tableName;
$whereClause = $this->buildWhereClause();
if ($whereClause) {
$sql .= ' WHERE ' . $whereClause;
}
$orderClause = $this->buildOrderClause();
if ($orderClause) {
$sql .= ' ORDER BY ' . $orderClause;
}
$limitClause = $this->buildLimitClause();
if ($limitClause) {
$sql .= ' ' . $limitClause;
}
return $sql;
}
/**
* Get all parameters for prepared statement
*/
public function getParameters(): array
{
$parameters = [];
foreach ($this->criteria->getCriteria() as $criterion) {
$parameters = array_merge($parameters, $criterion->getParameters());
}
return $parameters;
}
private function buildSelectClause(): string
{
$projection = $this->criteria->getProjection();
return $projection ? $projection->toSql() : '*';
}
private function buildWhereClause(): string
{
$criteria = $this->criteria->getCriteria();
if (empty($criteria)) {
return '';
}
if (count($criteria) === 1) {
return $criteria[0]->toSql();
}
// Multiple criteria are combined with AND
$conditions = array_map(fn ($c) => $c->toSql(), $criteria);
return '(' . implode(') AND (', $conditions) . ')';
}
private function buildOrderClause(): string
{
$orders = $this->criteria->getOrders();
if (empty($orders)) {
return '';
}
return implode(', ', array_map(fn ($o) => $o->toSql(), $orders));
}
private function buildLimitClause(): string
{
$maxResults = $this->criteria->getMaxResults();
$firstResult = $this->criteria->getFirstResult();
if ($maxResults === null && $firstResult === null) {
return '';
}
$clause = 'LIMIT';
if ($firstResult !== null) {
$clause .= " {$firstResult},";
}
if ($maxResults !== null) {
$clause .= " {$maxResults}";
} else {
$clause .= " 18446744073709551615"; // MySQL max value
}
return $clause;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
/**
* Base interface for all query criteria (WHERE conditions)
*/
interface Criterion
{
/**
* Convert criterion to SQL WHERE clause
*/
public function toSql(): string;
/**
* Get parameter values for prepared statement
*/
public function getParameters(): array;
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
/**
* Detached criteria implementation - can be used without session
*/
final class DetachedCriteria implements Criteria
{
/** @var Criterion[] */
private array $criteria = [];
/** @var Order[] */
private array $orders = [];
private ?Projection $projection = null;
private ?int $maxResults = null;
private ?int $firstResult = null;
public function __construct(
public readonly string $entityClass
) {
}
public static function forClass(string $entityClass): self
{
return new self($entityClass);
}
public function add(Criterion $criterion): self
{
$this->criteria[] = $criterion;
return $this;
}
public function setProjection(Projection $projection): self
{
$this->projection = $projection;
return $this;
}
public function addOrder(Order $order): self
{
$this->orders[] = $order;
return $this;
}
public function setMaxResults(?int $maxResults): self
{
$this->maxResults = $maxResults;
return $this;
}
public function setFirstResult(?int $firstResult): self
{
$this->firstResult = $firstResult;
return $this;
}
public function getCriteria(): array
{
return $this->criteria;
}
public function getProjection(): ?Projection
{
return $this->projection;
}
public function getOrders(): array
{
return $this->orders;
}
public function getMaxResults(): ?int
{
return $this->maxResults;
}
public function getFirstResult(): ?int
{
return $this->firstResult;
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Example;
use App\Framework\Database\Criteria\DetachedCriteria;
use App\Framework\Database\Criteria\Order;
use App\Framework\Database\Criteria\Projections;
use App\Framework\Database\Criteria\Restrictions;
use App\Framework\Database\EntityManager;
/**
* Example usage of the Criteria API
*/
final class CriteriaUsageExample
{
public function __construct(
private readonly EntityManager $entityManager
) {
}
/**
* Basic equality query
*/
public function findUsersByEmail(string $email): array
{
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::eq('email', $email))
->addOrder(Order::asc('name'));
return $this->entityManager->findByCriteria($criteria);
}
/**
* Complex query with multiple conditions
*/
public function findActiveUsersWithPosts(): array
{
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::eq('active', true))
->add(Restrictions::gt('post_count', 0))
->add(Restrictions::isNotNull('last_login'))
->addOrder(Order::desc('last_login'))
->setMaxResults(50);
return $this->entityManager->findByCriteria($criteria);
}
/**
* Range query
*/
public function findUsersByAge(int $minAge, int $maxAge): array
{
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::between('age', $minAge, $maxAge))
->addOrder(Order::asc('age'));
return $this->entityManager->findByCriteria($criteria);
}
/**
* IN query with multiple values
*/
public function findUsersByIds(array $userIds): array
{
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::in('id', $userIds))
->addOrder(Order::asc('name'));
return $this->entityManager->findByCriteria($criteria);
}
/**
* LIKE query for pattern matching
*/
public function searchUsersByName(string $namePattern): array
{
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::like('name', "%{$namePattern}%"))
->addOrder(Order::asc('name'))
->setMaxResults(20);
return $this->entityManager->findByCriteria($criteria);
}
/**
* Complex logical conditions
*/
public function findVipOrActiveUsers(): array
{
$criteria = DetachedCriteria::forClass(User::class)
->add(
Restrictions::or(
Restrictions::eq('vip', true),
Restrictions::and(
Restrictions::eq('active', true),
Restrictions::gt('login_count', 10)
)
)
)
->addOrder(Order::desc('created_at'));
return $this->entityManager->findByCriteria($criteria);
}
/**
* Multiple criteria with allOf/anyOf
*/
public function findPremiumUsers(): array
{
$criteria = DetachedCriteria::forClass(User::class)
->add(
Restrictions::allOf(
Restrictions::eq('subscription_type', 'premium'),
Restrictions::gt('subscription_expires', date('Y-m-d')),
Restrictions::eq('payment_status', 'paid')
)
)
->addOrder(Order::desc('subscription_expires'));
return $this->entityManager->findByCriteria($criteria);
}
/**
* Projection queries (aggregates)
*/
public function getUserStatistics(): array
{
$criteria = DetachedCriteria::forClass(User::class)
->setProjection(
Projections::projectionList(
Projections::count('*', 'total_users'),
Projections::avg('age', 'average_age'),
Projections::max('created_at', 'newest_user'),
Projections::sum('post_count', 'total_posts')
)
);
return $this->entityManager->findByCriteria($criteria);
}
/**
* Count matching entities
*/
public function countActiveUsers(): int
{
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::eq('active', true));
return $this->entityManager->countByCriteria($criteria);
}
/**
* Pagination example
*/
public function getUsersPage(int $page, int $pageSize = 10): array
{
$offset = ($page - 1) * $pageSize;
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::eq('active', true))
->addOrder(Order::asc('name'))
->setFirstResult($offset)
->setMaxResults($pageSize);
return $this->entityManager->findByCriteria($criteria);
}
/**
* Find single entity
*/
public function findUserByEmail(string $email): ?User
{
$criteria = DetachedCriteria::forClass(User::class)
->add(Restrictions::eq('email', $email));
return $this->entityManager->findOneByCriteria($criteria);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Expression;
use App\Framework\Database\Criteria\Criterion;
/**
* BETWEEN expression for range queries
*/
final readonly class BetweenExpression implements Criterion
{
public function __construct(
public string $property,
public mixed $low,
public mixed $high
) {
}
public function toSql(): string
{
return "{$this->property} BETWEEN ? AND ?";
}
public function getParameters(): array
{
return [$this->low, $this->high];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Expression;
use App\Framework\Database\Criteria\Criterion;
/**
* IN expression for matching against multiple values
*/
final readonly class InExpression implements Criterion
{
public function __construct(
public string $property,
public array $values
) {
}
public function toSql(): string
{
if (empty($this->values)) {
return '1=0'; // Always false
}
$placeholders = str_repeat('?,', count($this->values) - 1) . '?';
return "{$this->property} IN ({$placeholders})";
}
public function getParameters(): array
{
return $this->values;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Expression;
use App\Framework\Database\Criteria\Criterion;
/**
* LIKE expression for pattern matching
*/
final readonly class LikeExpression implements Criterion
{
public function __construct(
public string $property,
public string $pattern,
public bool $caseSensitive = true
) {
}
public function toSql(): string
{
$operator = $this->caseSensitive ? 'LIKE' : 'ILIKE';
return "{$this->property} {$operator} ?";
}
public function getParameters(): array
{
return [$this->pattern];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Expression;
use App\Framework\Database\Criteria\Criterion;
/**
* Logical expression for combining criteria with AND/OR
*/
final readonly class LogicalExpression implements Criterion
{
public function __construct(
public Criterion $left,
public Criterion $right,
public string $operator = 'AND'
) {
}
public function toSql(): string
{
return "({$this->left->toSql()}) {$this->operator} ({$this->right->toSql()})";
}
public function getParameters(): array
{
return array_merge($this->left->getParameters(), $this->right->getParameters());
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Expression;
use App\Framework\Database\Criteria\Criterion;
/**
* NOT expression for negating criteria
*/
final readonly class NotExpression implements Criterion
{
public function __construct(
public Criterion $criterion
) {
}
public function toSql(): string
{
return "NOT ({$this->criterion->toSql()})";
}
public function getParameters(): array
{
return $this->criterion->getParameters();
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Expression;
use App\Framework\Database\Criteria\Criterion;
/**
* Simple property comparison expression
*/
final readonly class SimpleExpression implements Criterion
{
public function __construct(
public string $property,
public mixed $value,
public string $operator = '='
) {
}
public function toSql(): string
{
if ($this->value === null) {
return match($this->operator) {
'=' => "{$this->property} IS NULL",
'!=' => "{$this->property} IS NOT NULL",
default => throw new \InvalidArgumentException("Cannot use operator {$this->operator} with NULL value")
};
}
return "{$this->property} {$this->operator} ?";
}
public function getParameters(): array
{
return $this->value === null ? [] : [$this->value];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
/**
* Order specification for sorting results
*/
final class Order
{
private function __construct(
public readonly string $property,
public readonly bool $ascending
) {
}
public static function asc(string $property): self
{
return new self($property, true);
}
public static function desc(string $property): self
{
return new self($property, false);
}
public function toSql(): string
{
$direction = $this->ascending ? 'ASC' : 'DESC';
return "{$this->property} {$direction}";
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
/**
* Interface for query projections (SELECT clause)
*/
interface Projection
{
/**
* Convert projection to SQL SELECT clause
*/
public function toSql(): string;
/**
* Get aliases for result mapping
*/
public function getAliases(): array;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Projection;
use App\Framework\Database\Criteria\Projection;
/**
* Aggregate function projection (COUNT, SUM, AVG, etc.)
*/
final class AggregateProjection implements Projection
{
public function __construct(
public readonly string $function,
public readonly string $property,
public readonly ?string $alias = null
) {
}
public function toSql(): string
{
$sql = "{$this->function}({$this->property})";
return $this->alias ? "{$sql} AS {$this->alias}" : $sql;
}
public function getAliases(): array
{
return $this->alias ? [$this->alias => "{$this->function}({$this->property})"] : [];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Projection;
use App\Framework\Database\Criteria\Projection;
/**
* List of multiple projections
*/
final readonly class ProjectionList implements Projection
{
/** @var Projection[] */
public array $projections;
public function __construct(Projection ...$projections)
{
$this->projections = $projections;
}
public function add(Projection $projection): self
{
$newProjections = [...$this->projections, $projection];
return new self(...$newProjections);
}
public function toSql(): string
{
if (empty($this->projections)) {
return '*';
}
return implode(', ', array_map(fn ($p) => $p->toSql(), $this->projections));
}
public function getAliases(): array
{
$aliases = [];
foreach ($this->projections as $projection) {
$aliases = array_merge($aliases, $projection->getAliases());
}
return $aliases;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria\Projection;
use App\Framework\Database\Criteria\Projection;
/**
* Simple property projection
*/
final readonly class PropertyProjection implements Projection
{
public function __construct(
public string $property,
public ?string $alias = null
) {
}
public function toSql(): string
{
return $this->alias ? "{$this->property} AS {$this->alias}" : $this->property;
}
public function getAliases(): array
{
return $this->alias ? [$this->alias => $this->property] : [];
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
use App\Framework\Database\Criteria\Projection\{
AggregateProjection,
ProjectionList,
PropertyProjection
};
/**
* Static factory class for creating projections
*/
final class Projections
{
private function __construct()
{
}
/**
* Property projection
*/
public static function property(string $property, ?string $alias = null): Projection
{
return new PropertyProjection($property, $alias);
}
/**
* COUNT aggregate
*/
public static function count(string $property = '*', ?string $alias = null): Projection
{
return new AggregateProjection('COUNT', $property, $alias);
}
/**
* COUNT DISTINCT aggregate
*/
public static function countDistinct(string $property, ?string $alias = null): Projection
{
return new AggregateProjection('COUNT', "DISTINCT {$property}", $alias);
}
/**
* SUM aggregate
*/
public static function sum(string $property, ?string $alias = null): Projection
{
return new AggregateProjection('SUM', $property, $alias);
}
/**
* AVG aggregate
*/
public static function avg(string $property, ?string $alias = null): Projection
{
return new AggregateProjection('AVG', $property, $alias);
}
/**
* MIN aggregate
*/
public static function min(string $property, ?string $alias = null): Projection
{
return new AggregateProjection('MIN', $property, $alias);
}
/**
* MAX aggregate
*/
public static function max(string $property, ?string $alias = null): Projection
{
return new AggregateProjection('MAX', $property, $alias);
}
/**
* Projection list
*/
public static function projectionList(Projection ...$projections): Projection
{
return new ProjectionList(...$projections);
}
/**
* Row count projection (shorthand)
*/
public static function rowCount(?string $alias = 'count'): Projection
{
return self::count('*', $alias);
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Criteria;
use App\Framework\Database\Criteria\Expression\{
BetweenExpression,
InExpression,
LikeExpression,
LogicalExpression,
NotExpression,
SimpleExpression
};
/**
* Static factory class for creating common restrictions
*/
final class Restrictions
{
private function __construct()
{
}
/**
* Equality restriction
*/
public static function eq(string $property, mixed $value): Criterion
{
return new SimpleExpression($property, $value, '=');
}
/**
* Not equal restriction
*/
public static function ne(string $property, mixed $value): Criterion
{
return new SimpleExpression($property, $value, '!=');
}
/**
* Less than restriction
*/
public static function lt(string $property, mixed $value): Criterion
{
return new SimpleExpression($property, $value, '<');
}
/**
* Less than or equal restriction
*/
public static function le(string $property, mixed $value): Criterion
{
return new SimpleExpression($property, $value, '<=');
}
/**
* Greater than restriction
*/
public static function gt(string $property, mixed $value): Criterion
{
return new SimpleExpression($property, $value, '>');
}
/**
* Greater than or equal restriction
*/
public static function ge(string $property, mixed $value): Criterion
{
return new SimpleExpression($property, $value, '>=');
}
/**
* LIKE restriction
*/
public static function like(string $property, string $pattern, bool $caseSensitive = true): Criterion
{
return new LikeExpression($property, $pattern, $caseSensitive);
}
/**
* Case-insensitive LIKE restriction
*/
public static function ilike(string $property, string $pattern): Criterion
{
return new LikeExpression($property, $pattern, false);
}
/**
* IN restriction
*/
public static function in(string $property, array $values): Criterion
{
return new InExpression($property, $values);
}
/**
* NOT IN restriction
*/
public static function notIn(string $property, array $values): Criterion
{
return new NotExpression(new InExpression($property, $values));
}
/**
* BETWEEN restriction
*/
public static function between(string $property, mixed $low, mixed $high): Criterion
{
return new BetweenExpression($property, $low, $high);
}
/**
* IS NULL restriction
*/
public static function isNull(string $property): Criterion
{
return new SimpleExpression($property, null, '=');
}
/**
* IS NOT NULL restriction
*/
public static function isNotNull(string $property): Criterion
{
return new SimpleExpression($property, null, '!=');
}
/**
* AND conjunction
*/
public static function and(Criterion $left, Criterion $right): Criterion
{
return new LogicalExpression($left, $right, 'AND');
}
/**
* OR disjunction
*/
public static function or(Criterion $left, Criterion $right): Criterion
{
return new LogicalExpression($left, $right, 'OR');
}
/**
* NOT negation
*/
public static function not(Criterion $criterion): Criterion
{
return new NotExpression($criterion);
}
/**
* Conjunction of multiple criteria
*/
public static function allOf(Criterion ...$criteria): Criterion
{
if (empty($criteria)) {
throw new \InvalidArgumentException('At least one criterion is required');
}
$result = array_shift($criteria);
foreach ($criteria as $criterion) {
$result = self::and($result, $criterion);
}
return $result;
}
/**
* Disjunction of multiple criteria
*/
public static function anyOf(Criterion ...$criteria): Criterion
{
if (empty($criteria)) {
throw new \InvalidArgumentException('At least one criterion is required');
}
$result = array_shift($criteria);
foreach ($criteria as $criterion) {
$result = self::or($result, $criterion);
}
return $result;
}
}

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Cache\Cache;
use App\Framework\Database\Cache\CacheAdapterStrategy;
use App\Framework\Database\Cache\CacheStrategy;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Cache\SimpleCacheStrategy;
use App\Framework\Database\Driver\Driver;
use App\Framework\Database\Driver\DriverConfig;
use App\Framework\Database\Driver\DriverType;
@@ -14,12 +15,10 @@ 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 App\Framework\Database\Middleware\MiddlewarePipeline;
use App\Framework\Database\Middleware\RetryMiddleware;
use Pdo\Mysql;
use Pdo\Pgsql;
use Pdo\Sqlite;
@@ -35,7 +34,7 @@ final readonly class DatabaseFactory
$healthCheck = $middlewareConfig['health_check'] ?? false;
$cache = $middlewareConfig['cache'] ?? false;
if (!$lazy && !$retry && !$healthCheck && !$cache) {
if (! $lazy && ! $retry && ! $healthCheck && ! $cache) {
// Keine Middleware - direkte Verbindung
return self::createDirectConnection($config);
}
@@ -89,7 +88,7 @@ final readonly class DatabaseFactory
public static function createDirectConnection(array|DriverConfig $config): ConnectionInterface
{
if(is_array($config)) {
if (is_array($config)) {
$config = DriverConfig::fromArray($config);
}
@@ -117,8 +116,8 @@ final readonly class DatabaseFactory
return self::createConnection($config, [
'retry' => [
'max_retries' => $maxRetries,
'delay_ms' => $retryDelayMs
]
'delay_ms' => $retryDelayMs,
],
]);
}
@@ -131,12 +130,12 @@ final readonly class DatabaseFactory
'lazy' => true,
'retry' => [
'max_retries' => 3,
'delay_ms' => 100
'delay_ms' => 100,
],
'health_check' => [
'interval' => 30,
'enabled' => true
]
'enabled' => true,
],
];
return self::createConnection($config, array_merge($defaultConfig, $middlewareConfig));
@@ -152,6 +151,7 @@ final readonly class DatabaseFactory
{
if ($connection instanceof MiddlewareConnection) {
$baseConnection = $connection->getBaseConnection();
return LazyConnectionFactory::isLazyGhost($baseConnection);
}
@@ -205,8 +205,8 @@ final readonly class DatabaseFactory
'ttl' => 300,
'enabled' => true,
'prefix' => 'db_query:',
'tags' => ['database_query']
], $cacheConfig)
'tags' => ['database_query'],
], $cacheConfig),
]);
}
@@ -216,7 +216,7 @@ final readonly class DatabaseFactory
array $additionalConfig = []
): ConnectionInterface {
return self::createCachedConnection($config, array_merge([
'cache_instance' => $cache
'cache_instance' => $cache,
], $additionalConfig));
}
@@ -234,8 +234,8 @@ final readonly class DatabaseFactory
'cache_instance' => $cache,
'prefix' => $keyPrefix,
'ttl' => $ttl,
'enabled' => true
]
'enabled' => true,
],
]);
}
@@ -250,16 +250,16 @@ final readonly class DatabaseFactory
'ttl' => 300,
'enabled' => true,
'prefix' => 'db_query:',
'tags' => ['database_query']
'tags' => ['database_query'],
],
'retry' => [
'max_retries' => 3,
'delay_ms' => 100
'delay_ms' => 100,
],
'health_check' => [
'interval' => 30,
'enabled' => true
]
'enabled' => true,
],
];
return self::createConnection($config, array_merge($defaultConfig, $middlewareConfig));
@@ -277,16 +277,16 @@ final readonly class DatabaseFactory
'ttl' => 1800,
'enabled' => true,
'prefix' => 'prod_db:',
'tags' => ['database_query', 'production']
'tags' => ['database_query', 'production'],
],
'retry' => [
'max_retries' => 5,
'delay_ms' => 200
'delay_ms' => 200,
],
'health_check' => [
'interval' => 60,
'enabled' => true
]
'enabled' => true,
],
];
// Wenn externe Cache-Instanz übergeben wird

View File

@@ -4,43 +4,65 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\Migration\MigrationLoader;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Database\Profiling\ProfileSummary;
use App\Framework\Database\Profiling\ProfilingConnection;
use App\Framework\Database\Profiling\ProfilingDashboard;
use App\Framework\Database\Profiling\QueryLogger;
use App\Framework\Database\Profiling\QueryProfiler;
use App\Framework\Database\Profiling\SlowQueryDetector;
use App\Framework\Database\ReadWrite\ReplicationLagDetector;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\Timer;
use App\Framework\Logging\Logger;
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;
private readonly DatabaseConfig $config,
private readonly Timer $timer,
private readonly string $migrationsPath = 'database/migrations',
private readonly ?Clock $clock = null,
private readonly ?Logger $logger = null,
private readonly ?EventDispatcher $eventDispatcher = null
) {
}
public function getConnection(): ConnectionInterface
{
if (isset($this->config['pool']) && $this->config['pool']['enabled']) {
return $this->getPooledConnection();
$connection = null;
if ($this->config->poolConfig->enabled) {
$connection = $this->getPooledConnection();
} elseif ($this->config->readWriteConfig->enabled) {
$connection = $this->getReadWriteConnection();
} else {
$connection = DatabaseFactory::createConnection($this->config->driverConfig);
}
if (isset($this->config['read_write']) && $this->config['read_write']['enabled']) {
return $this->getReadWriteConnection();
// Wrap with profiling if enabled
if ($this->config->profilingConfig->enabled) {
$connection = $this->wrapWithProfiling($connection);
}
return DatabaseFactory::createConnection($this->config);
return $connection;
}
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
$this->config->driverConfig,
$this->config->poolConfig,
$this->timer
);
}
@@ -50,21 +72,83 @@ final class DatabaseManager
public function getReadWriteConnection(): ReadWriteConnection
{
if ($this->readWriteConnection === null) {
$writeConnection = DatabaseFactory::createConnection($this->config);
$writeConnection = DatabaseFactory::createConnection($this->config->driverConfig);
$readConnections = [];
foreach ($this->config['read_write']['read_connections'] as $readConfig) {
$readConnections[] = DatabaseFactory::createConnection(
array_merge($this->config, $readConfig)
);
foreach ($this->config->readWriteConfig->readConnections as $readConfig) {
$readConnections[] = DatabaseFactory::createConnection($readConfig);
}
$this->readWriteConnection = new ReadWriteConnection($writeConnection, $readConnections);
// Create advanced connection with routing if dependencies are available
if ($this->clock) {
$lagDetector = new ReplicationLagDetector($this->clock, $this->timer);
$this->readWriteConnection = new ReadWriteConnection(
$writeConnection,
$readConnections,
$this->config->readWriteConfig,
$this->clock,
$lagDetector
);
} else {
// Fallback to simple connection
$this->readWriteConnection = new ReadWriteConnection($writeConnection, $readConnections);
}
}
return $this->readWriteConnection;
}
/**
* Wrap connection with profiling capabilities
*/
private function wrapWithProfiling(ConnectionInterface $connection): ProfilingConnection
{
if (! $this->clock) {
throw new \RuntimeException('Clock is required for database profiling');
}
// Create profiler
$profiler = new QueryProfiler(
$this->clock,
$this->config->profilingConfig->slowQueryThreshold->toSeconds()
);
// Create logger if logging is enabled
$queryLogger = null;
if ($this->logger) {
$queryLogger = new QueryLogger(
$this->logger,
$this->config->profilingConfig->slowQueryThreshold,
$this->config->profilingConfig->logSlowQueriesOnly,
$this->config->profilingConfig->logParameters,
$this->config->profilingConfig->logStackTrace,
$this->config->profilingConfig->maxLoggedQueries
);
}
// Create profiling connection
$profilingConnection = new ProfilingConnection($connection, $profiler, $queryLogger);
// Set up slow query detection if enabled
if ($this->config->profilingConfig->enableSlowQueryDetection &&
$this->eventDispatcher &&
$queryLogger) {
$slowQueryDetector = new SlowQueryDetector(
$this->eventDispatcher,
$this->clock,
$this->config->profilingConfig->slowQueryThreshold,
Duration::fromSeconds($this->config->profilingConfig->slowQueryThreshold->toSeconds() * 5)
);
// Register event listener to detect slow queries from logged queries
// This would be done in a real application through proper event handling
}
return $profilingConnection;
}
public function migrate(?string $migrationsPath = null): array
{
$migrationsPath = $migrationsPath ?? $this->migrationsPath;
@@ -73,6 +157,7 @@ final class DatabaseManager
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
return $runner->migrate($migrations);
}
@@ -84,6 +169,7 @@ final class DatabaseManager
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
return $runner->rollback($migrations, $steps);
}
@@ -95,9 +181,58 @@ final class DatabaseManager
$migrations = $loader->loadMigrations();
$runner = new MigrationRunner($this->getConnection());
return $runner->getStatus($migrations);
}
/**
* Register this database manager with a profiling dashboard
*/
public function registerWithProfilingDashboard(ProfilingDashboard $dashboard, string $connectionName = 'default'): void
{
$connection = $this->getConnection();
if ($connection instanceof ProfilingConnection) {
$dashboard->registerConnection($connectionName, $connection);
}
}
/**
* Get profiling statistics for this database manager
*/
public function getProfilingStatistics(): ?array
{
$connection = $this->getConnection();
if ($connection instanceof ProfilingConnection) {
return $connection->getProfilingStatistics();
}
return null;
}
/**
* Get profiling summary for this database manager
*/
public function getProfilingSummary(): ?ProfileSummary
{
$connection = $this->getConnection();
if ($connection instanceof ProfilingConnection) {
return $connection->getProfilingSummary();
}
return null;
}
/**
* Check if profiling is enabled
*/
public function isProfilingEnabled(): bool
{
return $this->config->profilingConfig->enabled;
}
public function getConnectionPoolStats(): array
{
return $this->connectionPool?->getStats() ?? [];

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver;
interface Driver

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver;
final readonly class DriverConfig
@@ -11,8 +13,11 @@ final readonly class DriverConfig
public string $database,
public string $username,
public string $password,
public string $charset
){}
public string $charset,
public int $weight = 100,
public int $maxConnections = 100
) {
}
public static function fromArray(array $config): DriverConfig
{
@@ -23,7 +28,9 @@ final readonly class DriverConfig
database: $config['database'],
username: $config['username'],
password: $config['password'],
charset: $config['charset'] ?? 'utf8mb4'
charset: $config['charset'] ?? 'utf8mb4',
weight: $config['weight'] ?? 100,
maxConnections: $config['max_connections'] ?? 100
);
}
}

View File

@@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver;
enum DriverType:string
enum DriverType: string
{
case MYSQL = 'mysql';

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver;
final readonly class MysqlDriver implements Driver
@@ -10,7 +12,7 @@ final readonly class MysqlDriver implements Driver
public function __construct(
public DriverConfig $config,
){
) {
$this->dsn = $this->createDns();
$this->options = $this->getOptions();
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver\Optimization;
/**
* Interface for database-specific optimizations
*/
interface DatabaseOptimizer
{
/**
* Optimize a table or all tables in the database
*
* @param string|null $table The table to optimize, or null for all tables
* @return array<string, string> Optimization results by table
*/
public function optimizeTables(?string $table = null): array;
/**
* Analyze a table or all tables in the database
*
* @param string|null $table The table to analyze, or null for all tables
* @return array<string, string> Analysis results by table
*/
public function analyzeTables(?string $table = null): array;
/**
* Check a table or all tables in the database
*
* @param string|null $table The table to check, or null for all tables
* @param bool $extended Whether to perform an extended check
* @return array<string, string> Check results by table
*/
public function checkTables(?string $table = null, bool $extended = false): array;
/**
* Get table status information
*
* @param string|null $table The table to get status for, or null for all tables
* @return array<string, array<string, mixed>> Table status by table
*/
public function getTableStatus(?string $table = null): array;
/**
* Get index statistics
*
* @param string|null $table The table to get index statistics for, or null for all tables
* @return array<string, array<string, array<string, mixed>>> Index statistics by table and index
*/
public function getIndexStatistics(?string $table = null): array;
}

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver\Optimization;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
/**
* MySQL-specific database optimizations
*/
final readonly class MySQLOptimizer implements DatabaseOptimizer
{
public function __construct(
private ConnectionInterface $connection
) {
// Verify this is a MySQL connection
$driver = $this->connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
if ($driver !== 'mysql') {
throw new DatabaseException("MySQLOptimizer requires a MySQL connection, got {$driver}");
}
}
/**
* Optimize a table or all tables in the database
*
* @param string|null $table The table to optimize, or null for all tables
* @return array<string, string> Optimization results by table
*/
public function optimizeTables(?string $table = null): array
{
$results = [];
if ($table !== null) {
// Optimize a specific table
$sql = "OPTIMIZE TABLE " . $this->quoteIdentifier($table);
$result = $this->connection->query($sql)->fetchAll();
$results[$table] = $result[0]['Msg_text'] ?? 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Optimize each table
foreach ($tables as $tableName) {
$sql = "OPTIMIZE TABLE " . $this->quoteIdentifier($tableName);
$result = $this->connection->query($sql)->fetchAll();
$results[$tableName] = $result[0]['Msg_text'] ?? 'OK';
}
}
return $results;
}
/**
* Analyze a table or all tables in the database
*
* @param string|null $table The table to analyze, or null for all tables
* @return array<string, string> Analysis results by table
*/
public function analyzeTables(?string $table = null): array
{
$results = [];
if ($table !== null) {
// Analyze a specific table
$sql = "ANALYZE TABLE " . $this->quoteIdentifier($table);
$result = $this->connection->query($sql)->fetchAll();
$results[$table] = $result[0]['Msg_text'] ?? 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Analyze each table
foreach ($tables as $tableName) {
$sql = "ANALYZE TABLE " . $this->quoteIdentifier($tableName);
$result = $this->connection->query($sql)->fetchAll();
$results[$tableName] = $result[0]['Msg_text'] ?? 'OK';
}
}
return $results;
}
/**
* Check a table or all tables in the database
*
* @param string|null $table The table to check, or null for all tables
* @param bool $extended Whether to perform an extended check
* @return array<string, string> Check results by table
*/
public function checkTables(?string $table = null, bool $extended = false): array
{
$results = [];
$checkType = $extended ? 'EXTENDED' : 'QUICK';
if ($table !== null) {
// Check a specific table
$sql = "CHECK TABLE " . $this->quoteIdentifier($table) . " {$checkType}";
$result = $this->connection->query($sql)->fetchAll();
$results[$table] = $result[0]['Msg_text'] ?? 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Check each table
foreach ($tables as $tableName) {
$sql = "CHECK TABLE " . $this->quoteIdentifier($tableName) . " {$checkType}";
$result = $this->connection->query($sql)->fetchAll();
$results[$tableName] = $result[0]['Msg_text'] ?? 'OK';
}
}
return $results;
}
/**
* Repair a table or all tables in the database
*
* @param string|null $table The table to repair, or null for all tables
* @param bool $quick Whether to perform a quick repair
* @return array<string, string> Repair results by table
*/
public function repairTables(?string $table = null, bool $quick = true): array
{
$results = [];
$repairType = $quick ? 'QUICK' : '';
if ($table !== null) {
// Repair a specific table
$sql = "REPAIR TABLE " . $this->quoteIdentifier($table) . ($repairType ? " {$repairType}" : "");
$result = $this->connection->query($sql)->fetchAll();
$results[$table] = $result[0]['Msg_text'] ?? 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Repair each table
foreach ($tables as $tableName) {
$sql = "REPAIR TABLE " . $this->quoteIdentifier($tableName) . ($repairType ? " {$repairType}" : "");
$result = $this->connection->query($sql)->fetchAll();
$results[$tableName] = $result[0]['Msg_text'] ?? 'OK';
}
}
return $results;
}
/**
* Get table status information
*
* @param string|null $table The table to get status for, or null for all tables
* @return array<string, array<string, mixed>> Table status by table
*/
public function getTableStatus(?string $table = null): array
{
$results = [];
$where = $table !== null ? " WHERE Name = ?" : "";
$params = $table !== null ? [$table] : [];
$sql = "SHOW TABLE STATUS" . $where;
$rows = $this->connection->query($sql, $params)->fetchAll();
foreach ($rows as $row) {
$tableName = $row['Name'];
$results[$tableName] = $row;
}
return $results;
}
/**
* Get index statistics
*
* @param string|null $table The table to get index statistics for, or null for all tables
* @return array<string, array<string, array<string, mixed>>> Index statistics by table and index
*/
public function getIndexStatistics(?string $table = null): array
{
$results = [];
if ($table !== null) {
// Get index statistics for a specific table
$results[$table] = $this->getTableIndexStatistics($table);
} else {
// Get all tables
$tables = $this->getAllTables();
// Get index statistics for each table
foreach ($tables as $tableName) {
$results[$tableName] = $this->getTableIndexStatistics($tableName);
}
}
return $results;
}
/**
* Get query cache statistics
*
* @return array<string, mixed> Query cache statistics
*/
public function getQueryCacheStatistics(): array
{
$sql = "SHOW GLOBAL STATUS LIKE 'Qcache%'";
$rows = $this->connection->query($sql)->fetchAll();
$results = [];
foreach ($rows as $row) {
$results[$row['Variable_name']] = $row['Value'];
}
return $results;
}
/**
* Get buffer pool statistics
*
* @return array<string, mixed> Buffer pool statistics
*/
public function getBufferPoolStatistics(): array
{
$sql = "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%'";
$rows = $this->connection->query($sql)->fetchAll();
$results = [];
foreach ($rows as $row) {
$results[$row['Variable_name']] = $row['Value'];
}
return $results;
}
/**
* Get slow query log status
*
* @return array<string, mixed> Slow query log status
*/
public function getSlowQueryLogStatus(): array
{
$sql = "SHOW VARIABLES LIKE 'slow_query%'";
$rows = $this->connection->query($sql)->fetchAll();
$results = [];
foreach ($rows as $row) {
$results[$row['Variable_name']] = $row['Value'];
}
$sql = "SHOW VARIABLES LIKE 'long_query_time'";
$row = $this->connection->queryOne($sql);
if ($row) {
$results['long_query_time'] = $row['Value'];
}
return $results;
}
/**
* Enable or disable the slow query log
*
* @param bool $enabled Whether to enable the slow query log
* @param float|null $longQueryTime The time threshold for slow queries (in seconds)
* @param string|null $logFile The path to the slow query log file
* @return bool Whether the operation was successful
*/
public function setSlowQueryLog(bool $enabled, ?float $longQueryTime = null, ?string $logFile = null): bool
{
try {
// Set slow query log
$sql = "SET GLOBAL slow_query_log = ?";
$this->connection->execute($sql, [$enabled ? 1 : 0]);
// Set long query time if provided
if ($longQueryTime !== null) {
$sql = "SET GLOBAL long_query_time = ?";
$this->connection->execute($sql, [$longQueryTime]);
}
// Set log file if provided
if ($logFile !== null) {
$sql = "SET GLOBAL slow_query_log_file = ?";
$this->connection->execute($sql, [$logFile]);
}
return true;
} catch (\Throwable $e) {
throw new DatabaseException("Failed to set slow query log: " . $e->getMessage(), 0, $e);
}
}
/**
* Get all tables in the current database
*
* @return array<string> List of table names
*/
private function getAllTables(): array
{
$sql = "SHOW TABLES";
$rows = $this->connection->query($sql)->fetchAll();
$tables = [];
foreach ($rows as $row) {
$tables[] = reset($row); // First column contains table name
}
return $tables;
}
/**
* Get index statistics for a specific table
*
* @param string $table The table to get index statistics for
* @return array<string, array<string, mixed>> Index statistics by index
*/
private function getTableIndexStatistics(string $table): array
{
$results = [];
// Get index information
$sql = "SHOW INDEX FROM " . $this->quoteIdentifier($table);
$rows = $this->connection->query($sql)->fetchAll();
// Group by index name
$indexGroups = [];
foreach ($rows as $row) {
$indexName = $row['Key_name'];
if (! isset($indexGroups[$indexName])) {
$indexGroups[$indexName] = [];
}
$indexGroups[$indexName][] = $row;
}
// Process each index
foreach ($indexGroups as $indexName => $indexRows) {
$firstRow = $indexRows[0];
$results[$indexName] = [
'type' => $firstRow['Index_type'],
'unique' => $firstRow['Non_unique'] == 0,
'cardinality' => array_sum(array_column($indexRows, 'Cardinality')),
'columns' => array_column($indexRows, 'Column_name'),
'nullable' => in_array('YES', array_column($indexRows, 'Null')),
];
}
return $results;
}
/**
* Quote an identifier (table or column name)
*
* @param string $identifier The identifier to quote
* @return string The quoted identifier
*/
private function quoteIdentifier(string $identifier): string
{
return '`' . str_replace('`', '``', $identifier) . '`';
}
}

View File

@@ -0,0 +1,434 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver\Optimization;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
/**
* PostgreSQL-specific database optimizations
*/
final readonly class PostgreSQLOptimizer implements DatabaseOptimizer
{
public function __construct(
private ConnectionInterface $connection,
private ?string $schema = 'public'
) {
// Verify this is a PostgreSQL connection
$driver = $this->connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
if ($driver !== 'pgsql') {
throw new DatabaseException("PostgreSQLOptimizer requires a PostgreSQL connection, got {$driver}");
}
}
/**
* Optimize a table or all tables in the database
* In PostgreSQL, this is equivalent to VACUUM FULL ANALYZE
*
* @param string|null $table The table to optimize, or null for all tables
* @return array<string, string> Optimization results by table
*/
public function optimizeTables(?string $table = null): array
{
$results = [];
if ($table !== null) {
// Optimize a specific table
$sql = "VACUUM FULL ANALYZE " . $this->quoteIdentifier($table);
$this->connection->execute($sql);
$results[$table] = 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Optimize each table
foreach ($tables as $tableName) {
$sql = "VACUUM FULL ANALYZE " . $this->quoteIdentifier($tableName);
$this->connection->execute($sql);
$results[$tableName] = 'OK';
}
}
return $results;
}
/**
* Analyze a table or all tables in the database
*
* @param string|null $table The table to analyze, or null for all tables
* @return array<string, string> Analysis results by table
*/
public function analyzeTables(?string $table = null): array
{
$results = [];
if ($table !== null) {
// Analyze a specific table
$sql = "ANALYZE " . $this->quoteIdentifier($table);
$this->connection->execute($sql);
$results[$table] = 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Analyze each table
foreach ($tables as $tableName) {
$sql = "ANALYZE " . $this->quoteIdentifier($tableName);
$this->connection->execute($sql);
$results[$tableName] = 'OK';
}
}
return $results;
}
/**
* Check a table or all tables in the database
* In PostgreSQL, we use pg_stat_user_tables to get table statistics
*
* @param string|null $table The table to check, or null for all tables
* @param bool $extended Whether to perform an extended check (not used in PostgreSQL)
* @return array<string, string> Check results by table
*/
public function checkTables(?string $table = null, bool $extended = false): array
{
$results = [];
$schema = $this->schema ?? 'public';
$sql = "SELECT relname as table_name,
n_live_tup as row_count,
n_dead_tup as dead_rows,
last_vacuum,
last_analyze
FROM pg_stat_user_tables
WHERE schemaname = ?";
$params = [$schema];
if ($table !== null) {
$sql .= " AND relname = ?";
$params[] = $table;
}
$rows = $this->connection->query($sql, $params)->fetchAll();
foreach ($rows as $row) {
$tableName = $row['table_name'];
$deadRowPercentage = $row['row_count'] > 0
? round(($row['dead_rows'] / $row['row_count']) * 100, 2)
: 0;
$status = 'OK';
// If more than 20% dead rows, suggest VACUUM
if ($deadRowPercentage > 20) {
$status = "Warning: {$deadRowPercentage}% dead rows, consider running VACUUM";
}
$results[$tableName] = $status;
}
return $results;
}
/**
* Repair tables - not directly supported in PostgreSQL
* This is a no-op method that returns a message indicating that repair is not needed
*
* @param string|null $table The table to repair, or null for all tables
* @return array<string, string> Repair results by table
*/
public function repairTables(?string $table = null): array
{
$results = [];
if ($table !== null) {
$results[$table] = 'PostgreSQL tables do not need repair; use VACUUM for maintenance';
} else {
// Get all tables
$tables = $this->getAllTables();
// Add message for each table
foreach ($tables as $tableName) {
$results[$tableName] = 'PostgreSQL tables do not need repair; use VACUUM for maintenance';
}
}
return $results;
}
/**
* Get table status information
*
* @param string|null $table The table to get status for, or null for all tables
* @return array<string, array<string, mixed>> Table status by table
*/
public function getTableStatus(?string $table = null): array
{
$results = [];
$schema = $this->schema ?? 'public';
$sql = "SELECT t.relname as table_name,
pg_size_pretty(pg_total_relation_size(t.oid)) as total_size,
pg_size_pretty(pg_relation_size(t.oid)) as table_size,
pg_size_pretty(pg_total_relation_size(t.oid) - pg_relation_size(t.oid)) as index_size,
s.n_live_tup as row_count,
s.n_dead_tup as dead_rows,
s.last_vacuum,
s.last_analyze,
s.vacuum_count,
s.analyze_count
FROM pg_class t
JOIN pg_stat_user_tables s ON t.relname = s.relname
WHERE t.relkind = 'r'
AND s.schemaname = ?";
$params = [$schema];
if ($table !== null) {
$sql .= " AND t.relname = ?";
$params[] = $table;
}
$rows = $this->connection->query($sql, $params)->fetchAll();
foreach ($rows as $row) {
$tableName = $row['table_name'];
$results[$tableName] = $row;
}
return $results;
}
/**
* Get index statistics
*
* @param string|null $table The table to get index statistics for, or null for all tables
* @return array<string, array<string, array<string, mixed>>> Index statistics by table and index
*/
public function getIndexStatistics(?string $table = null): array
{
$results = [];
$schema = $this->schema ?? 'public';
if ($table !== null) {
// Get index statistics for a specific table
$results[$table] = $this->getTableIndexStatistics($table);
} else {
// Get all tables
$sql = "SELECT tablename FROM pg_tables WHERE schemaname = ?";
$tables = $this->connection->query($sql, [$schema])->fetchAll(\PDO::FETCH_COLUMN);
// Get index statistics for each table
foreach ($tables as $tableName) {
$results[$tableName] = $this->getTableIndexStatistics($tableName);
}
}
return $results;
}
/**
* Get database cache statistics
*
* @return array<string, mixed> Cache statistics
*/
public function getCacheStatistics(): array
{
$sql = "SELECT
sum(heap_blks_read) as heap_read,
sum(heap_blks_hit) as heap_hit,
sum(idx_blks_read) as idx_read,
sum(idx_blks_hit) as idx_hit,
sum(toast_blks_read) as toast_read,
sum(toast_blks_hit) as toast_hit,
sum(tidx_blks_read) as tidx_read,
sum(tidx_blks_hit) as tidx_hit
FROM pg_statio_user_tables";
$row = $this->connection->queryOne($sql);
if (! $row) {
return [];
}
// Calculate hit ratios
$heapHitRatio = ($row['heap_hit'] + $row['heap_read']) > 0
? round($row['heap_hit'] * 100 / ($row['heap_hit'] + $row['heap_read']), 2)
: 0;
$idxHitRatio = ($row['idx_hit'] + $row['idx_read']) > 0
? round($row['idx_hit'] * 100 / ($row['idx_hit'] + $row['idx_read']), 2)
: 0;
$toastHitRatio = ($row['toast_hit'] + $row['toast_read']) > 0
? round($row['toast_hit'] * 100 / ($row['toast_hit'] + $row['toast_read']), 2)
: 0;
$tidxHitRatio = ($row['tidx_hit'] + $row['tidx_read']) > 0
? round($row['tidx_hit'] * 100 / ($row['tidx_hit'] + $row['tidx_read']), 2)
: 0;
return [
'heap_read' => $row['heap_read'],
'heap_hit' => $row['heap_hit'],
'heap_hit_ratio' => $heapHitRatio,
'idx_read' => $row['idx_read'],
'idx_hit' => $row['idx_hit'],
'idx_hit_ratio' => $idxHitRatio,
'toast_read' => $row['toast_read'],
'toast_hit' => $row['toast_hit'],
'toast_hit_ratio' => $toastHitRatio,
'tidx_read' => $row['tidx_read'],
'tidx_hit' => $row['tidx_hit'],
'tidx_hit_ratio' => $tidxHitRatio,
];
}
/**
* Get database settings
*
* @param string|null $pattern Pattern to filter settings
* @return array<string, mixed> Database settings
*/
public function getDatabaseSettings(?string $pattern = null): array
{
$sql = "SELECT name, setting, unit, context, vartype, short_desc
FROM pg_settings";
$params = [];
if ($pattern !== null) {
$sql .= " WHERE name LIKE ?";
$params[] = "%{$pattern}%";
}
$sql .= " ORDER BY name";
$rows = $this->connection->query($sql, $params)->fetchAll();
$results = [];
foreach ($rows as $row) {
$results[$row['name']] = [
'value' => $row['setting'],
'unit' => $row['unit'],
'context' => $row['context'],
'type' => $row['vartype'],
'description' => $row['short_desc'],
];
}
return $results;
}
/**
* Get long-running queries
*
* @param int $seconds Minimum query duration in seconds
* @return array<int, array<string, mixed>> Long-running queries
*/
public function getLongRunningQueries(int $seconds = 5): array
{
$sql = "SELECT pid,
usename,
application_name,
client_addr,
state,
wait_event_type,
wait_event,
query_start,
EXTRACT(EPOCH FROM (now() - query_start)) as duration,
query
FROM pg_stat_activity
WHERE state = 'active'
AND EXTRACT(EPOCH FROM (now() - query_start)) > ?
AND query NOT LIKE '%pg_stat_activity%'
ORDER BY duration DESC";
$rows = $this->connection->query($sql, [$seconds])->fetchAll();
return $rows;
}
/**
* Get all tables in the current schema
*
* @return array<string> List of table names
*/
private function getAllTables(): array
{
$schema = $this->schema ?? 'public';
$sql = "SELECT tablename FROM pg_tables WHERE schemaname = ?";
return $this->connection->query($sql, [$schema])->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get index statistics for a specific table
*
* @param string $table The table to get index statistics for
* @return array<string, array<string, mixed>> Index statistics by index
*/
private function getTableIndexStatistics(string $table): array
{
$results = [];
$schema = $this->schema ?? 'public';
$sql = "SELECT
i.relname as index_name,
a.attname as column_name,
ix.indisunique as is_unique,
ix.indisprimary as is_primary,
am.amname as index_type,
pg_size_pretty(pg_relation_size(i.oid)) as index_size,
s.idx_scan as index_scans,
s.idx_tup_read as tuples_read,
s.idx_tup_fetch as tuples_fetched
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_am am ON i.relam = am.oid
JOIN pg_namespace n ON n.oid = i.relnamespace
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
WHERE t.relname = ?
AND n.nspname = ?
ORDER BY i.relname, a.attnum";
$rows = $this->connection->query($sql, [$table, $schema])->fetchAll();
// Group by index name
$indexGroups = [];
foreach ($rows as $row) {
$indexName = $row['index_name'];
if (! isset($indexGroups[$indexName])) {
$indexGroups[$indexName] = [
'columns' => [],
'is_unique' => $row['is_unique'],
'is_primary' => $row['is_primary'],
'index_type' => $row['index_type'],
'index_size' => $row['index_size'],
'index_scans' => $row['index_scans'],
'tuples_read' => $row['tuples_read'],
'tuples_fetched' => $row['tuples_fetched'],
];
}
$indexGroups[$indexName]['columns'][] = $row['column_name'];
}
return $indexGroups;
}
/**
* Quote an identifier (table or column name)
*
* @param string $identifier The identifier to quote
* @return string The quoted identifier
*/
private function quoteIdentifier(string $identifier): string
{
return '"' . str_replace('"', '""', $identifier) . '"';
}
}

View File

@@ -0,0 +1,482 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver\Optimization;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
/**
* SQLite-specific database optimizations
*/
final readonly class SQLiteOptimizer implements DatabaseOptimizer
{
public function __construct(
private ConnectionInterface $connection
) {
// Verify this is a SQLite connection
$driver = $this->connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
if ($driver !== 'sqlite') {
throw new DatabaseException("SQLiteOptimizer requires a SQLite connection, got {$driver}");
}
}
/**
* Optimize a table or all tables in the database
* In SQLite, this is equivalent to VACUUM
*
* @param string|null $table The table to optimize, or null for all tables
* @return array<string, string> Optimization results by table
*/
public function optimizeTables(?string $table = null): array
{
$results = [];
try {
// SQLite VACUUM operates on the entire database, not individual tables
$this->connection->execute('VACUUM');
if ($table !== null) {
$results[$table] = 'OK (database-wide VACUUM performed)';
} else {
// Get all tables
$tables = $this->getAllTables();
// Add result for each table
foreach ($tables as $tableName) {
$results[$tableName] = 'OK (database-wide VACUUM performed)';
}
}
} catch (\Throwable $e) {
if ($table !== null) {
$results[$table] = 'Error: ' . $e->getMessage();
} else {
$results['database'] = 'Error: ' . $e->getMessage();
}
}
return $results;
}
/**
* Analyze a table or all tables in the database
*
* @param string|null $table The table to analyze, or null for all tables
* @return array<string, string> Analysis results by table
*/
public function analyzeTables(?string $table = null): array
{
$results = [];
try {
if ($table !== null) {
// Analyze a specific table
$sql = "ANALYZE " . $this->quoteIdentifier($table);
$this->connection->execute($sql);
$results[$table] = 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Analyze each table
foreach ($tables as $tableName) {
$sql = "ANALYZE " . $this->quoteIdentifier($tableName);
$this->connection->execute($sql);
$results[$tableName] = 'OK';
}
}
} catch (\Throwable $e) {
if ($table !== null) {
$results[$table] = 'Error: ' . $e->getMessage();
} else {
$results['database'] = 'Error: ' . $e->getMessage();
}
}
return $results;
}
/**
* Check a table or all tables in the database
* In SQLite, we use PRAGMA integrity_check
*
* @param string|null $table The table to check, or null for all tables
* @param bool $extended Whether to perform an extended check
* @return array<string, string> Check results by table
*/
public function checkTables(?string $table = null, bool $extended = false): array
{
$results = [];
try {
if ($extended) {
// Full database integrity check
$sql = "PRAGMA integrity_check";
$rows = $this->connection->query($sql)->fetchAll(\PDO::FETCH_COLUMN);
if (count($rows) === 1 && $rows[0] === 'ok') {
if ($table !== null) {
$results[$table] = 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Add result for each table
foreach ($tables as $tableName) {
$results[$tableName] = 'OK';
}
}
} else {
// There are integrity errors
if ($table !== null) {
$results[$table] = 'Integrity errors found: ' . implode(', ', $rows);
} else {
$results['database'] = 'Integrity errors found: ' . implode(', ', $rows);
}
}
} else {
// Quick check
$sql = "PRAGMA quick_check";
$rows = $this->connection->query($sql)->fetchAll(\PDO::FETCH_COLUMN);
if (count($rows) === 1 && $rows[0] === 'ok') {
if ($table !== null) {
$results[$table] = 'OK';
} else {
// Get all tables
$tables = $this->getAllTables();
// Add result for each table
foreach ($tables as $tableName) {
$results[$tableName] = 'OK';
}
}
} else {
// There are integrity errors
if ($table !== null) {
$results[$table] = 'Quick check errors found: ' . implode(', ', $rows);
} else {
$results['database'] = 'Quick check errors found: ' . implode(', ', $rows);
}
}
}
} catch (\Throwable $e) {
if ($table !== null) {
$results[$table] = 'Error: ' . $e->getMessage();
} else {
$results['database'] = 'Error: ' . $e->getMessage();
}
}
return $results;
}
/**
* Get table status information
*
* @param string|null $table The table to get status for, or null for all tables
* @return array<string, array<string, mixed>> Table status by table
*/
public function getTableStatus(?string $table = null): array
{
$results = [];
try {
if ($table !== null) {
// Get status for a specific table
$results[$table] = $this->getTableInfo($table);
} else {
// Get all tables
$tables = $this->getAllTables();
// Get status for each table
foreach ($tables as $tableName) {
$results[$tableName] = $this->getTableInfo($tableName);
}
}
} catch (\Throwable $e) {
// Handle error
if ($table !== null) {
$results[$table] = ['error' => $e->getMessage()];
} else {
$results['error'] = $e->getMessage();
}
}
return $results;
}
/**
* Get index statistics
*
* @param string|null $table The table to get index statistics for, or null for all tables
* @return array<string, array<string, array<string, mixed>>> Index statistics by table and index
*/
public function getIndexStatistics(?string $table = null): array
{
$results = [];
try {
if ($table !== null) {
// Get index statistics for a specific table
$results[$table] = $this->getTableIndexInfo($table);
} else {
// Get all tables
$tables = $this->getAllTables();
// Get index statistics for each table
foreach ($tables as $tableName) {
$results[$tableName] = $this->getTableIndexInfo($tableName);
}
}
} catch (\Throwable $e) {
// Handle error
if ($table !== null) {
$results[$table] = ['error' => $e->getMessage()];
} else {
$results['error'] = $e->getMessage();
}
}
return $results;
}
/**
* Get database statistics
*
* @return array<string, mixed> Database statistics
*/
public function getDatabaseStatistics(): array
{
$stats = [];
try {
// Get database size
$sql = "PRAGMA page_count";
$pageCount = (int)$this->connection->queryScalar($sql);
$sql = "PRAGMA page_size";
$pageSize = (int)$this->connection->queryScalar($sql);
$databaseSize = $pageCount * $pageSize;
$stats['page_count'] = $pageCount;
$stats['page_size'] = $pageSize;
$stats['database_size'] = $databaseSize;
$stats['database_size_human'] = $this->formatBytes($databaseSize);
// Get free pages
$sql = "PRAGMA freelist_count";
$freelistCount = (int)$this->connection->queryScalar($sql);
$stats['freelist_count'] = $freelistCount;
$stats['free_space'] = $freelistCount * $pageSize;
$stats['free_space_human'] = $this->formatBytes($freelistCount * $pageSize);
// Get schema version
$sql = "PRAGMA schema_version";
$stats['schema_version'] = (int)$this->connection->queryScalar($sql);
// Get user version
$sql = "PRAGMA user_version";
$stats['user_version'] = (int)$this->connection->queryScalar($sql);
// Get cache statistics
$sql = "PRAGMA cache_size";
$stats['cache_size'] = (int)$this->connection->queryScalar($sql);
// Get table count
$tables = $this->getAllTables();
$stats['table_count'] = count($tables);
// Get index count
$sql = "SELECT count(*) FROM sqlite_master WHERE type = 'index'";
$stats['index_count'] = (int)$this->connection->queryScalar($sql);
// Get trigger count
$sql = "SELECT count(*) FROM sqlite_master WHERE type = 'trigger'";
$stats['trigger_count'] = (int)$this->connection->queryScalar($sql);
// Get view count
$sql = "SELECT count(*) FROM sqlite_master WHERE type = 'view'";
$stats['view_count'] = (int)$this->connection->queryScalar($sql);
} catch (\Throwable $e) {
$stats['error'] = $e->getMessage();
}
return $stats;
}
/**
* Get SQLite PRAGMA settings
*
* @return array<string, mixed> PRAGMA settings
*/
public function getPragmaSettings(): array
{
$settings = [];
$pragmas = [
'auto_vacuum',
'automatic_index',
'busy_timeout',
'cache_size',
'case_sensitive_like',
'cell_size_check',
'checkpoint_fullfsync',
'foreign_keys',
'fullfsync',
'ignore_check_constraints',
'journal_mode',
'journal_size_limit',
'legacy_file_format',
'locking_mode',
'max_page_count',
'mmap_size',
'page_size',
'recursive_triggers',
'reverse_unordered_selects',
'secure_delete',
'synchronous',
'temp_store',
'wal_autocheckpoint',
];
foreach ($pragmas as $pragma) {
try {
$sql = "PRAGMA {$pragma}";
$value = $this->connection->queryScalar($sql);
$settings[$pragma] = $value;
} catch (\Throwable $e) {
// Skip pragmas that aren't supported in this SQLite version
}
}
return $settings;
}
/**
* Set a PRAGMA setting
*
* @param string $pragma The PRAGMA to set
* @param mixed $value The value to set
* @return bool Whether the operation was successful
*/
public function setPragma(string $pragma, mixed $value): bool
{
try {
$sql = "PRAGMA {$pragma} = " . (is_string($value) ? "'{$value}'" : $value);
$this->connection->execute($sql);
return true;
} catch (\Throwable $e) {
throw new DatabaseException("Failed to set PRAGMA {$pragma}: " . $e->getMessage(), 0, $e);
}
}
/**
* Get all tables in the database
*
* @return array<string> List of table names
*/
private function getAllTables(): array
{
$sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'";
return $this->connection->query($sql)->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get detailed information about a table
*
* @param string $table The table to get information for
* @return array<string, mixed> Table information
*/
private function getTableInfo(string $table): array
{
$info = [];
// Get table structure
$sql = "PRAGMA table_info(" . $this->quoteIdentifier($table) . ")";
$columns = $this->connection->query($sql)->fetchAll();
$info['columns'] = count($columns);
$info['column_details'] = $columns;
// Get row count (approximate)
$sql = "SELECT count(*) FROM " . $this->quoteIdentifier($table);
$info['row_count'] = (int)$this->connection->queryScalar($sql);
// Get index information
$info['indexes'] = $this->getTableIndexInfo($table);
// Get foreign key information
$sql = "PRAGMA foreign_key_list(" . $this->quoteIdentifier($table) . ")";
$info['foreign_keys'] = $this->connection->query($sql)->fetchAll();
return $info;
}
/**
* Get index information for a table
*
* @param string $table The table to get index information for
* @return array<string, array<string, mixed>> Index information by index
*/
private function getTableIndexInfo(string $table): array
{
$indexes = [];
// Get index list
$sql = "PRAGMA index_list(" . $this->quoteIdentifier($table) . ")";
$indexList = $this->connection->query($sql)->fetchAll();
foreach ($indexList as $index) {
$indexName = $index['name'];
// Get index columns
$sql = "PRAGMA index_info(" . $this->quoteIdentifier($indexName) . ")";
$indexInfo = $this->connection->query($sql)->fetchAll();
$indexes[$indexName] = [
'unique' => (bool)$index['unique'],
'columns' => array_column($indexInfo, 'name'),
];
}
return $indexes;
}
/**
* Format bytes to a human-readable string
*
* @param int $bytes The number of bytes
* @param int $precision The number of decimal places
* @return string The formatted string
*/
private function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
/**
* Quote an identifier (table or column name)
*
* @param string $identifier The identifier to quote
* @return string The quoted identifier
*/
private function quoteIdentifier(string $identifier): string
{
return '"' . str_replace('"', '""', $identifier) . '"';
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver;
final readonly class PostgresDriver implements Driver
@@ -10,7 +12,7 @@ final readonly class PostgresDriver implements Driver
public function __construct(
public DriverConfig $config,
){
) {
$this->dsn = $this->createDns();
$this->options = $this->getOptions();
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Driver;
final readonly class SqliteDriver implements Driver
@@ -10,7 +12,7 @@ final readonly class SqliteDriver implements Driver
public function __construct(
public DriverConfig $config,
){
) {
$this->dsn = $this->createDns();
$this->options = $this->getOptions();
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
interface EntityLoaderInterface
{
/**
* Find entity by primary key
*/
public function find(string $entityClass, mixed $id): ?object;
/**
* Find single entity by criteria
*/
public function findOneBy(string $entityClass, array $criteria): ?object;
/**
* Find multiple entities by criteria
*/
public function findBy(string $entityClass, array $criteria): array;
}

View File

@@ -1,24 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Attributes\Singleton;
use App\Framework\Database\Cache\EntityCacheManager;
use App\Framework\Database\Criteria\Criteria;
use App\Framework\Database\Criteria\CriteriaQuery;
use App\Framework\Database\Events\EntityEventManager;
use App\Framework\Database\Metadata\EntityMetadata;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\QueryBuilder\QueryBuilderFactory;
use App\Framework\Database\QueryBuilder\SelectQueryBuilder;
use App\Framework\Database\UnitOfWork\UnitOfWork;
#[Singleton]
final readonly class EntityManager
final readonly class EntityManager implements EntityLoaderInterface
{
private Hydrator $hydrator;
public function __construct(
private DatabaseManager $databaseManager,
private MetadataRegistry $metadataRegistry,
private TypeConverter $typeConverter,
private IdentityMap $identityMap,
private LazyLoader $lazyLoader
private LazyLoader $lazyLoader,
private HydratorInterface $hydrator,
private BatchRelationLoader $batchRelationLoader,
public UnitOfWork $unitOfWork,
private QueryBuilderFactory $queryBuilderFactory,
private EntityEventManager $entityEventManager,
private ?EntityCacheManager $cacheManager = null
) {
$this->hydrator = new Hydrator($this->typeConverter, $this);
}
/**
@@ -26,6 +38,14 @@ final readonly class EntityManager
*/
public function find(string $entityClass, mixed $id): ?object
{
// Use cache manager if available
if ($this->cacheManager !== null) {
return $this->cacheManager->findEntity($entityClass, $id, function () use ($entityClass, $id) {
return $this->findWithLazyLoading($entityClass, $id);
});
}
// Fallback to direct loading
// Prüfe Identity Map zuerst
if ($this->identityMap->has($entityClass, $id)) {
return $this->identityMap->get($entityClass, $id);
@@ -57,7 +77,7 @@ final readonly class EntityManager
$result = $this->databaseManager->getConnection()->query($query, [$id]);
$data = $result->fetch();
if (!$data) {
if (! $data) {
return null;
}
@@ -66,6 +86,9 @@ final readonly class EntityManager
// In Identity Map speichern
$this->identityMap->set($entityClass, $id, $entity);
// Entity Loaded Event dispatchen
$this->entityEventManager->entityLoaded($entity, $entityClass, $id, $data, false);
return $entity;
}
@@ -80,12 +103,17 @@ final readonly class EntityManager
$query = "SELECT {$metadata->idColumn} FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$result = $this->databaseManager->getConnection()->query($query, [$id]);
if (!$result->fetch()) {
if (! $result->fetch()) {
return null;
}
// Erstelle Lazy Ghost
return $this->lazyLoader->createLazyGhost($metadata, $id);
$entity = $this->lazyLoader->createLazyGhost($metadata, $id);
// Entity Loaded Event dispatchen (als Lazy)
$this->entityEventManager->entityLoaded($entity, $entityClass, $id, [], true);
return $entity;
}
/**
@@ -99,6 +127,7 @@ final readonly class EntityManager
}
$metadata = $this->metadataRegistry->getMetadata($entityClass);
return $this->lazyLoader->createLazyGhost($metadata, $id);
}
@@ -173,13 +202,28 @@ final readonly class EntityManager
* Findet Entities nach Kriterien
*/
public function findBy(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
// Use cache manager if available
if ($this->cacheManager !== null) {
return $this->cacheManager->findCollection($entityClass, $criteria, $orderBy, $limit, null, function () use ($entityClass, $criteria, $orderBy, $limit) {
return $this->findByWithoutCache($entityClass, $criteria, $orderBy, $limit);
});
}
return $this->findByWithoutCache($entityClass, $criteria, $orderBy, $limit);
}
/**
* Internal method for finding entities without cache
*/
private function findByWithoutCache(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT {$metadata->idColumn} FROM {$metadata->tableName}";
$params = [];
if (!empty($criteria)) {
if (! empty($criteria)) {
$conditions = [];
foreach ($criteria as $field => $value) {
$columnName = $metadata->getColumnName($field);
@@ -219,9 +263,110 @@ final readonly class EntityManager
public function findOneBy(string $entityClass, array $criteria): ?object
{
$results = $this->findBy($entityClass, $criteria, limit: 1);
return $results[0] ?? null;
}
/**
* Findet Entities mit vorab geladenen Relationen (N+1 Solution)
*
* @param string $entityClass
* @param array $criteria
* @param array $relations Relations to preload ['comments', 'author', etc.]
* @param array|null $orderBy
* @param int|null $limit
* @return array<object>
*/
public function findWithRelations(
string $entityClass,
array $criteria = [],
array $relations = [],
?array $orderBy = null,
?int $limit = null
): array {
// Step 1: Load base entities (using existing findBy logic but eager)
$entities = $this->findByEager($entityClass, $criteria, $orderBy, $limit);
if (empty($entities) || empty($relations)) {
return $entities;
}
// Step 2: Preload each specified relation in batch
foreach ($relations as $relationName) {
$this->batchRelationLoader->preloadRelation($entities, $relationName);
}
return $entities;
}
/**
* Eager version of findBy - loads full entities immediately
* Used internally by findWithRelations
*/
private function findByEager(string $entityClass, array $criteria, ?array $orderBy = null, ?int $limit = null): array
{
$metadata = $this->metadataRegistry->getMetadata($entityClass);
$query = "SELECT * FROM {$metadata->tableName}";
$params = [];
if (! empty($criteria)) {
$conditions = [];
foreach ($criteria as $field => $value) {
if (is_array($value)) {
// Handle IN queries for batch loading
$placeholders = str_repeat('?,', count($value) - 1) . '?';
$columnName = $metadata->getColumnName($field);
$conditions[] = "{$columnName} IN ({$placeholders})";
$params = array_merge($params, array_values($value));
} else {
$columnName = $metadata->getColumnName($field);
$conditions[] = "{$columnName} = ?";
$params[] = $value;
}
}
$query .= " WHERE " . implode(' AND ', $conditions);
}
if ($orderBy) {
$orderClauses = [];
foreach ($orderBy as $field => $direction) {
$columnName = $metadata->getColumnName($field);
$orderClauses[] = "{$columnName} " . strtoupper($direction);
}
$query .= " ORDER BY " . implode(', ', $orderClauses);
}
if ($limit) {
$query .= " LIMIT {$limit}";
}
$result = $this->databaseManager->getConnection()->query($query, $params);
$entities = [];
foreach ($result->fetchAll() as $data) {
$idValue = $data[$metadata->idColumn];
// Check identity map first
if ($this->identityMap->has($entityClass, $idValue)) {
$entity = $this->identityMap->get($entityClass, $idValue);
// If it's a lazy ghost, initialize it for eager loading
if ($this->isLazyGhost($entity)) {
$this->initializeLazyObject($entity);
}
$entities[] = $entity;
} else {
$entity = $this->hydrator->hydrate($metadata, $data);
$this->identityMap->set($entityClass, $idValue, $entity);
$entities[] = $entity;
}
}
return $entities;
}
/**
* Utility Methods
*/
@@ -240,7 +385,12 @@ final readonly class EntityManager
try {
$property = $metadata->reflection->getProperty($paramName);
$id = $property->getValue($entity);
// Entity Detached Event dispatchen (vor Identity Map Remove)
$this->entityEventManager->entityDetached($entity, $entity::class, $id);
$this->identityMap->remove($entity::class, $id);
break;
} catch (\ReflectionException) {
// Property nicht gefunden
@@ -283,6 +433,72 @@ final readonly class EntityManager
return IdGenerator::generate();
}
/**
* Get profiling statistics if profiling is enabled
*/
public function getProfilingStatistics(): ?array
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->getProfilingStatistics();
}
return null;
}
/**
* Get profiling summary if profiling is enabled
*/
public function getProfilingSummary(): ?\App\Framework\Database\Profiling\ProfileSummary
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->getProfilingSummary();
}
return null;
}
/**
* Clear profiling data if profiling is enabled
*/
public function clearProfilingData(): void
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
$connection->clearProfilingData();
}
}
/**
* Enable or disable profiling at runtime
*/
public function setProfilingEnabled(bool $enabled): void
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
$connection->setProfilingEnabled($enabled);
}
}
/**
* Check if profiling is enabled
*/
public function isProfilingEnabled(): bool
{
$connection = $this->databaseManager->getConnection();
if ($connection instanceof \App\Framework\Database\Profiling\ProfilingConnection) {
return $connection->isProfilingEnabled();
}
return false;
}
/**
* Speichert eine Entity (INSERT oder UPDATE)
*/
@@ -295,10 +511,17 @@ final readonly class EntityManager
// Prüfe ob Entity bereits existiert
if ($this->exists($entity::class, $id)) {
return $this->update($entity);
$result = $this->update($entity);
} else {
return $this->insert($entity);
$result = $this->insert($entity);
}
// Cache the entity after successful save
if ($this->cacheManager !== null) {
$this->cacheManager->cacheEntity($result);
}
return $result;
}
/**
@@ -337,7 +560,7 @@ final readonly class EntityManager
// Property-Wert auslesen
$property = $metadata->reflection->getProperty($propertyName);
if(!$property->isInitialized($entity)) {
if (! $property->isInitialized($entity)) {
continue;
}
@@ -360,6 +583,9 @@ final readonly class EntityManager
$id = $idProperty->getValue($entity);
$this->identityMap->set($entity::class, $id, $entity);
// Entity Created Event dispatchen
$this->entityEventManager->entityCreated($entity, $entity::class, $id, $params);
return $entity;
}
@@ -370,6 +596,16 @@ final readonly class EntityManager
{
$metadata = $this->metadataRegistry->getMetadata($entity::class);
// ID für Change Tracking und WHERE Clause
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
// Change Tracking: Original Entity aus IdentityMap laden
$originalEntity = $this->identityMap->get($entity::class, $id);
$changes = [];
$oldValues = [];
$newValues = [];
// SET-Clause und Params aufbauen
$setClause = [];
$params = [];
@@ -377,7 +613,7 @@ final readonly class EntityManager
foreach ($metadata->properties as $propertyName => $propertyMetadata) {
// Relations beim Update ignorieren
if($propertyMetadata->isRelation) {
if ($propertyMetadata->isRelation) {
continue;
}
@@ -386,16 +622,35 @@ final readonly class EntityManager
continue;
}
$setClause[] = "{$propertyMetadata->columnName} = ?";
// Property-Wert auslesen
$property = $metadata->reflection->getProperty($propertyName);
$params[] = $property->getValue($entity);
$newValue = $property->getValue($entity);
// Change Tracking: Vergleiche mit Original-Werten
$oldValue = null;
if ($originalEntity !== null) {
$originalProperty = $metadata->reflection->getProperty($propertyName);
$oldValue = $originalProperty->getValue($originalEntity);
}
// Nur bei Änderungen in SET clause aufnehmen und Changes tracken
if ($originalEntity === null || $oldValue !== $newValue) {
$setClause[] = "{$propertyMetadata->columnName} = ?";
$params[] = $newValue;
// Change Tracking Daten sammeln
$changes[] = $propertyName;
$oldValues[$propertyName] = $oldValue;
$newValues[$propertyName] = $newValue;
}
}
// Wenn keine Änderungen vorliegen, kein UPDATE ausführen
if (empty($setClause)) {
return $entity;
}
// ID für WHERE Clause hinzufügen
$idProperty = $metadata->reflection->getProperty($metadata->idProperty);
$id = $idProperty->getValue($entity);
$params[] = $id;
// UPDATE Query bauen
@@ -408,6 +663,9 @@ final readonly class EntityManager
// Identity Map aktualisieren
$this->identityMap->set($entity::class, $id, $entity);
// Entity Updated Event mit Change Tracking dispatchen
$this->entityEventManager->entityUpdated($entity, $entity::class, $id, $changes, $oldValues, $newValues);
return $entity;
}
@@ -426,6 +684,14 @@ final readonly class EntityManager
$query = "DELETE FROM {$metadata->tableName} WHERE {$metadata->idColumn} = ?";
$this->databaseManager->getConnection()->query($query, [$id]);
// Evict from cache
if ($this->cacheManager !== null) {
$this->cacheManager->evictEntity($entity);
}
// Entity Deleted Event dispatchen (vor Identity Map Remove)
$this->entityEventManager->entityDeleted($entity, $entity::class, $id, []);
// Aus Identity Map entfernen
$this->identityMap->remove($entity::class, $id);
}
@@ -439,6 +705,7 @@ final readonly class EntityManager
foreach ($entities as $entity) {
$result[] = $this->save($entity);
}
return $result;
}
@@ -460,10 +727,163 @@ final readonly class EntityManager
try {
$result = $callback($this);
$connection->commit();
return $result;
} catch (\Throwable $e) {
$connection->rollback();
throw $e;
}
}
/**
* Execute criteria query and return entities
*/
public function findByCriteria(Criteria $criteria): array
{
$metadata = $this->metadataRegistry->getMetadata($criteria->entityClass);
$criteriaQuery = new CriteriaQuery($criteria, $metadata->tableName);
$sql = $criteriaQuery->toSql();
$parameters = $criteriaQuery->getParameters();
$result = $this->databaseManager->getConnection()->query($sql, $parameters);
$entities = [];
foreach ($result->fetchAll() as $data) {
// Check if it's a projection query (non-entity result)
$projection = $criteria->getProjection();
if ($projection && count($projection->getAliases()) > 0) {
// Return raw data for projection queries
$entities[] = $data;
} else {
// Normal entity hydration
$idValue = $data[$metadata->idColumn];
if ($this->identityMap->has($criteria->entityClass, $idValue)) {
$entities[] = $this->identityMap->get($criteria->entityClass, $idValue);
} else {
$entity = $this->hydrator->hydrate($metadata, $data);
$this->identityMap->set($criteria->entityClass, $idValue, $entity);
$entities[] = $entity;
}
}
}
return $entities;
}
/**
* Execute criteria query and return first result
*/
public function findOneByCriteria(Criteria $criteria): mixed
{
$criteria->setMaxResults(1);
$results = $this->findByCriteria($criteria);
return $results[0] ?? null;
}
/**
* Count entities matching criteria
*/
public function countByCriteria(Criteria $criteria): int
{
$metadata = $this->metadataRegistry->getMetadata($criteria->entityClass);
// Create count criteria
$countCriteria = clone $criteria;
$countCriteria->setProjection(\App\Framework\Database\Criteria\Projections::count());
$countCriteria->setMaxResults(null);
$countCriteria->setFirstResult(null);
$criteriaQuery = new CriteriaQuery($countCriteria, $metadata->tableName);
$sql = $criteriaQuery->toSql();
$parameters = $criteriaQuery->getParameters();
$result = $this->databaseManager->getConnection()->queryScalar($sql, $parameters);
return (int) $result;
}
/**
* Create a new query builder
*/
public function createQueryBuilder(): SelectQueryBuilder
{
return $this->queryBuilderFactory->select();
}
/**
* Create a query builder for a specific entity
*/
public function createQueryBuilderFor(string $entityClass): SelectQueryBuilder
{
return $this->queryBuilderFactory->selectFromWithEntity($entityClass, $this->identityMap, $this->hydrator);
}
/**
* Create a query builder for a table
*/
public function createQueryBuilderForTable(string $tableName): SelectQueryBuilder
{
return $this->queryBuilderFactory->selectFromTable($tableName);
}
/**
* Get the identity map (for QueryBuilder integration)
*/
public function getIdentityMap(): IdentityMap
{
return $this->identityMap;
}
/**
* Get the hydrator (for QueryBuilder integration)
*/
public function getHydrator(): HydratorInterface
{
return $this->hydrator;
}
/**
* Get the entity event manager
*/
public function getEntityEventManager(): EntityEventManager
{
return $this->entityEventManager;
}
/**
* Record a domain event for an entity
*/
public function recordDomainEvent(object $entity, object $event): void
{
$this->entityEventManager->recordDomainEvent($entity, $event);
}
/**
* Dispatch all domain events for an entity
*/
public function dispatchDomainEventsForEntity(object $entity): void
{
$this->entityEventManager->dispatchDomainEventsForEntity($entity);
}
/**
* Dispatch all domain events across all entities
*/
public function dispatchAllDomainEvents(): void
{
$this->entityEventManager->dispatchAllDomainEvents();
}
/**
* Get domain event statistics
*/
public function getDomainEventStats(): array
{
return $this->entityEventManager->getDomainEventStats();
}
}

View File

@@ -4,34 +4,77 @@ declare(strict_types=1);
namespace App\Framework\Database;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Database\Cache\EntityCacheManager;
use App\Framework\Database\Events\DomainEventCollector;
use App\Framework\Database\Events\EntityEventManager;
use App\Framework\Database\Metadata\MetadataExtractor;
use App\Framework\Database\Metadata\MetadataRegistry;
use App\Framework\Database\QueryBuilder\QueryBuilderFactory;
use App\Framework\Database\TypeCaster\TypeCasterRegistry;
use App\Framework\Database\UnitOfWork\UnitOfWorkFactory;
use App\Framework\DateTime\Clock;
final class EntityManagerFactory
{
public static function create(DatabaseManager $databaseManager): EntityManager
public static function create(DatabaseManager $databaseManager, EventDispatcher $eventDispatcher, Clock $clock, ?EntityCacheManager $cacheManager = null): EntityManager
{
$metadataExtractor = new MetadataExtractor();
$metadataRegistry = new MetadataRegistry($metadataExtractor);
$casterRegistry = new TypeCasterRegistry();
$typeConverter = new TypeConverter($casterRegistry);
#$hydrator = new Hydrator($typeConverter);
$identityMap = new IdentityMap();
// Create Hydrator without EntityLoader to break circular dependency
$hydrator = new Hydrator($typeConverter);
// Create BatchRelationLoader
$batchRelationLoader = new BatchRelationLoader($databaseManager, $metadataRegistry, $identityMap, $hydrator);
$lazyLoader = new LazyLoader(
databaseManager: $databaseManager,
typeConverter: $typeConverter,
identityMap: $identityMap,
metadataRegistry: $metadataRegistry
metadataRegistry: $metadataRegistry,
batchRelationLoader: $batchRelationLoader
);
return new EntityManager(
// Create UnitOfWork
$unitOfWork = UnitOfWorkFactory::create(
$databaseManager,
$metadataRegistry,
$identityMap,
$hydrator,
$typeConverter
);
// Create QueryBuilderFactory without circular dependency
$queryBuilderFactory = new QueryBuilderFactory(
$databaseManager->getConnection(),
$metadataRegistry,
$identityMap,
$hydrator
);
// Create Event System
$domainEventCollector = new DomainEventCollector();
$entityEventManager = new EntityEventManager($eventDispatcher, $domainEventCollector, $clock);
// Create EntityManager with all components
$entityManager = new EntityManager(
databaseManager: $databaseManager,
metadataRegistry: $metadataRegistry,
typeConverter: $typeConverter,
identityMap: $identityMap,
lazyLoader: $lazyLoader
lazyLoader: $lazyLoader,
hydrator: $hydrator,
batchRelationLoader: $batchRelationLoader,
unitOfWork: $unitOfWork,
queryBuilderFactory: $queryBuilderFactory,
entityEventManager: $entityEventManager,
cacheManager: $cacheManager
);
return $entityManager;
}
}

View File

@@ -1,55 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database;
use App\Domain\User\User;
use App\Framework\Attributes\Singleton;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Database\Cache\EntityCacheManager;
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\DateTime\Clock;
use App\Framework\DateTime\Timer;
use App\Framework\DI\Container;
use App\Framework\DI\Initializer;
use App\Framework\Logging\Logger;
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',
];
$databaseConfig = $container->get(DatabaseConfig::class);
$eventDispatcher = $container->get(EventDispatcher::class);
$clock = $container->get(Clock::class);
$timer = $container->get(Timer::class);
$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,
),
// Get optional dependencies for profiling
$logger = null;
if ($databaseConfig->profilingConfig->enabled && $container->has(Logger::class)) {
$logger = $container->get(Logger::class);
}
$db = new DatabaseManager(
$databaseConfig,
$timer,
'database/migrations',
$clock,
$logger,
$eventDispatcher
);
#$connection = DatabaseFactory::createConnection($config);
$db = new DatabaseManager($config);
$container->singleton(DatabaseManager::class, $db);
$container->singleton(ConnectionInterface::class, $db->getConnection());
return EntityManagerFactory::create($db);
// Only register ConnectionInterface if not already registered
if (! $container->has(ConnectionInterface::class)) {
// Lazy connection - only create when requested
$container->singleton(ConnectionInterface::class, fn () => $db->getConnection());
}
// Get cache manager if caching is enabled
$cacheManager = null;
if ($databaseConfig->cacheConfig->enabled) {
$cacheManager = $container->get(EntityCacheManager::class);
}
return EntityManagerFactory::create($db, $eventDispatcher, $clock, $cacheManager);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events;
/**
* External collector for domain events using WeakMap
* Keeps entities clean as simple value objects with automatic memory management
*/
final class DomainEventCollector
{
/** @var \WeakMap<object, object[]> Events mapped to entities with automatic GC */
private \WeakMap $eventsByEntity;
public function __construct()
{
$this->eventsByEntity = new \WeakMap();
}
/**
* Record a domain event for an entity
*/
public function recordEvent(object $entity, object $event): void
{
if (! isset($this->eventsByEntity[$entity])) {
$this->eventsByEntity[$entity] = [];
}
$this->eventsByEntity[$entity][] = $event;
}
/**
* Get all recorded domain events for an entity
*
* @return object[]
*/
public function getEventsForEntity(object $entity): array
{
return $this->eventsByEntity[$entity] ?? [];
}
/**
* Clear all recorded domain events for an entity
* (Optional - WeakMap handles cleanup automatically)
*/
public function clearEventsForEntity(object $entity): void
{
unset($this->eventsByEntity[$entity]);
}
/**
* Get all recorded domain events across all entities
*
* @return object[]
*/
public function getAllEvents(): array
{
$allEvents = [];
foreach ($this->eventsByEntity as $events) {
$allEvents = array_merge($allEvents, $events);
}
return $allEvents;
}
/**
* Check if there are any domain events for an entity
*/
public function hasEventsForEntity(object $entity): bool
{
return isset($this->eventsByEntity[$entity]) && ! empty($this->eventsByEntity[$entity]);
}
/**
* Get domain events of a specific type for an entity
*
* @template T
* @param class-string<T> $eventClass
* @return T[]
*/
public function getEventsOfTypeForEntity(object $entity, string $eventClass): array
{
$events = $this->getEventsForEntity($entity);
return array_filter(
$events,
fn (object $event) => $event instanceof $eventClass
);
}
/**
* Get count of events for an entity
*/
public function getEventCountForEntity(object $entity): int
{
return count($this->getEventsForEntity($entity));
}
/**
* Get total count of all events across all entities
*/
public function getTotalEventCount(): int
{
$total = 0;
foreach ($this->eventsByEntity as $events) {
$total += count($events);
}
return $total;
}
/**
* Get count of tracked entities
*/
public function getTrackedEntityCount(): int
{
return count($this->eventsByEntity);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event that is fired when an entity is created
*/
final readonly class EntityCreatedEvent
{
public Timestamp $timestamp;
public function __construct(
public object $entity,
public string $entityClass,
public mixed $entityId,
public array $data = [],
?Timestamp $timestamp = null
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
/**
* Check if this event is for a specific entity class
*/
public function isEntityOfType(string $expectedClass): bool
{
return $this->entityClass === $expectedClass || $this->entity instanceof $expectedClass;
}
/**
* Get event metadata
*/
public function getEventData(): array
{
return [
'event_type' => 'entity_created',
'entity_class' => $this->entityClass,
'entity_id' => $this->entityId,
'timestamp' => $this->timestamp->toFloat(),
'data' => $this->data,
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event that is fired when an entity is deleted
*/
final readonly class EntityDeletedEvent
{
public Timestamp $timestamp;
public function __construct(
public object $entity,
public string $entityClass,
public mixed $entityId,
public array $deletedData = [],
?Timestamp $timestamp = null
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
/**
* Check if this event is for a specific entity class
*/
public function isEntityOfType(string $expectedClass): bool
{
return $this->entityClass === $expectedClass || $this->entity instanceof $expectedClass;
}
/**
* Get event metadata
*/
public function getEventData(): array
{
return [
'event_type' => 'entity_deleted',
'entity_class' => $this->entityClass,
'entity_id' => $this->entityId,
'timestamp' => $this->timestamp->toFloat(),
'deleted_data' => $this->deletedData,
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event that is fired when an entity is detached from the entity manager
*/
final readonly class EntityDetachedEvent
{
public Timestamp $timestamp;
public function __construct(
public object $entity,
public string $entityClass,
public mixed $entityId,
?Timestamp $timestamp = null
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
/**
* Check if this event is for a specific entity class
*/
public function isEntityOfType(string $expectedClass): bool
{
return $this->entityClass === $expectedClass || $this->entity instanceof $expectedClass;
}
/**
* Get event metadata
*/
public function getEventData(): array
{
return [
'event_type' => 'entity_detached',
'entity_class' => $this->entityClass,
'entity_id' => $this->entityId,
'timestamp' => $this->timestamp->toFloat(),
];
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Clock;
/**
* Manages entity lifecycle events and coordinates with the core event system
*/
final class EntityEventManager
{
public function __construct(
private readonly EventDispatcher $eventDispatcher,
private readonly DomainEventCollector $domainEventCollector,
private readonly Clock $clock
) {
}
/**
* Dispatch an entity lifecycle event
*/
public function dispatchLifecycleEvent(object $event): void
{
$this->eventDispatcher->dispatch($event);
}
/**
* Record a domain event for later dispatch
*/
public function recordDomainEvent(object $entity, object $event): void
{
$this->domainEventCollector->recordEvent($entity, $event);
}
/**
* Dispatch all domain events for an entity and clear them
*/
public function dispatchDomainEventsForEntity(object $entity): void
{
$events = $this->domainEventCollector->getEventsForEntity($entity);
foreach ($events as $event) {
$this->eventDispatcher->dispatch($event);
}
$this->domainEventCollector->clearEventsForEntity($entity);
}
/**
* Dispatch all domain events across all entities and clear them
*/
public function dispatchAllDomainEvents(): void
{
$allEvents = $this->domainEventCollector->getAllEvents();
foreach ($allEvents as $event) {
$this->eventDispatcher->dispatch($event);
}
// Events are automatically cleared when entities are garbage collected
// But we can manually clear for immediate cleanup
foreach ($this->domainEventCollector->getAllEvents() as $entity) {
$this->domainEventCollector->clearEventsForEntity($entity);
}
}
/**
* Create and dispatch an EntityCreatedEvent
*/
public function entityCreated(object $entity, string $entityClass, mixed $entityId, array $data = []): void
{
$timestamp = Timestamp::fromClock($this->clock);
$event = new EntityCreatedEvent($entity, $entityClass, $entityId, $data, $timestamp);
$this->dispatchLifecycleEvent($event);
}
/**
* Create and dispatch an EntityUpdatedEvent
*/
public function entityUpdated(
object $entity,
string $entityClass,
mixed $entityId,
array $changes = [],
array $oldValues = [],
array $newValues = []
): void {
$timestamp = Timestamp::fromClock($this->clock);
$event = new EntityUpdatedEvent($entity, $entityClass, $entityId, $changes, $oldValues, $newValues, $timestamp);
$this->dispatchLifecycleEvent($event);
}
/**
* Create and dispatch an EntityDeletedEvent
*/
public function entityDeleted(object $entity, string $entityClass, mixed $entityId, array $deletedData = []): void
{
$timestamp = Timestamp::fromClock($this->clock);
$event = new EntityDeletedEvent($entity, $entityClass, $entityId, $deletedData, $timestamp);
$this->dispatchLifecycleEvent($event);
}
/**
* Create and dispatch an EntityLoadedEvent
*/
public function entityLoaded(
object $entity,
string $entityClass,
mixed $entityId,
array $loadedData = [],
bool $wasLazy = false
): void {
$timestamp = Timestamp::fromClock($this->clock);
$event = new EntityLoadedEvent($entity, $entityClass, $entityId, $loadedData, $wasLazy, $timestamp);
$this->dispatchLifecycleEvent($event);
}
/**
* Create and dispatch an EntityDetachedEvent
*/
public function entityDetached(object $entity, string $entityClass, mixed $entityId): void
{
$timestamp = Timestamp::fromClock($this->clock);
$event = new EntityDetachedEvent($entity, $entityClass, $entityId, $timestamp);
$this->dispatchLifecycleEvent($event);
}
/**
* Get domain event collector for advanced usage
*/
public function getDomainEventCollector(): DomainEventCollector
{
return $this->domainEventCollector;
}
/**
* Get core event dispatcher for advanced usage
*/
public function getEventDispatcher(): EventDispatcher
{
return $this->eventDispatcher;
}
/**
* Get statistics about domain events
*/
public function getDomainEventStats(): array
{
return [
'tracked_entities' => $this->domainEventCollector->getTrackedEntityCount(),
'total_events' => $this->domainEventCollector->getTotalEventCount(),
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event that is fired when an entity is loaded from database
*/
final readonly class EntityLoadedEvent
{
public Timestamp $timestamp;
public function __construct(
public object $entity,
public string $entityClass,
public mixed $entityId,
public array $loadedData = [],
public bool $wasLazy = false,
?Timestamp $timestamp = null,
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
/**
* Check if this event is for a specific entity class
*/
public function isEntityOfType(string $expectedClass): bool
{
return $this->entityClass === $expectedClass || $this->entity instanceof $expectedClass;
}
/**
* Get event metadata
*/
public function getEventData(): array
{
return [
'event_type' => 'entity_loaded',
'entity_class' => $this->entityClass,
'entity_id' => $this->entityId,
'timestamp' => $this->timestamp->toFloat(),
'was_lazy' => $this->wasLazy,
'loaded_data' => $this->loadedData,
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Event that is fired when an entity is updated
*/
final readonly class EntityUpdatedEvent
{
public Timestamp $timestamp;
public function __construct(
public object $entity,
public string $entityClass,
public mixed $entityId,
public array $changes = [],
public array $oldValues = [],
public array $newValues = [],
?Timestamp $timestamp = null
) {
$this->timestamp = $timestamp ?? Timestamp::now();
}
/**
* Check if this event is for a specific entity class
*/
public function isEntityOfType(string $expectedClass): bool
{
return $this->entityClass === $expectedClass || $this->entity instanceof $expectedClass;
}
/**
* Check if a specific field was changed
*/
public function hasFieldChanged(string $fieldName): bool
{
return in_array($fieldName, $this->changes, true);
}
/**
* Get the old value of a field
*/
public function getOldValue(string $fieldName): mixed
{
return $this->oldValues[$fieldName] ?? null;
}
/**
* Get the new value of a field
*/
public function getNewValue(string $fieldName): mixed
{
return $this->newValues[$fieldName] ?? null;
}
/**
* Get event metadata
*/
public function getEventData(): array
{
return [
'event_type' => 'entity_updated',
'entity_class' => $this->entityClass,
'entity_id' => $this->entityId,
'timestamp' => $this->timestamp->toFloat(),
'changes' => $this->changes,
'changed_fields_count' => count($this->changes),
];
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events\Examples;
use App\Framework\Database\EntityManager;
/**
* Comprehensive Entity Event System usage examples
*
* This demonstrates how to use the entity lifecycle events
* with the existing Core EventDispatcher system.
*/
final class EntityEventUsageExample
{
public function __construct(
private readonly EntityManager $entityManager
) {
}
/**
* Example: Basic lifecycle event handling
*/
public function basicLifecycleEventHandling(): void
{
// Create a new entity
$user = new ExampleUser('john@example.com', 'John Doe');
// The EntityManager will automatically dispatch EntityCreatedEvent
$this->entityManager->insert($user);
// Update the entity
$user = $user->updateEmail('john.doe@example.com');
// The EntityManager will automatically dispatch EntityUpdatedEvent
$this->entityManager->update($user);
// Delete the entity
// The EntityManager will automatically dispatch EntityDeletedEvent
$this->entityManager->delete($user);
}
/**
* Example: Domain event recording and dispatching
*/
public function domainEventHandling(): void
{
$user = new ExampleUser('jane@example.com', 'Jane Smith');
// Record domain events (these are not automatically dispatched)
$welcomeEvent = new UserWelcomeEmailEvent($user, 'jane@example.com');
$this->entityManager->recordDomainEvent($user, $welcomeEvent);
$analyticsEvent = new UserRegistrationAnalyticsEvent($user, 'web', 'organic');
$this->entityManager->recordDomainEvent($user, $analyticsEvent);
// Create the entity
$this->entityManager->insert($user);
// Manually dispatch domain events for this entity
$this->entityManager->dispatchDomainEventsForEntity($user);
// Or dispatch all domain events across all entities
// $this->entityManager->dispatchAllDomainEvents();
}
/**
* Example: Event statistics and monitoring
*/
public function eventStatistics(): array
{
// Get domain event statistics
$domainStats = $this->entityManager->getDomainEventStats();
return [
'tracked_entities' => $domainStats['tracked_entities'],
'pending_domain_events' => $domainStats['total_events'],
];
}
/**
* Example: Batch operations with events
*/
public function batchOperationsWithEvents(): void
{
$users = [
new ExampleUser('user1@example.com', 'User One'),
new ExampleUser('user2@example.com', 'User Two'),
new ExampleUser('user3@example.com', 'User Three'),
];
// Record domain events for each user
foreach ($users as $user) {
$event = new UserRegistrationAnalyticsEvent($user, 'batch_import', 'admin');
$this->entityManager->recordDomainEvent($user, $event);
}
// Save all users (will trigger EntityCreatedEvent for each)
$this->entityManager->saveAll(...$users);
// Dispatch all domain events at once
$this->entityManager->dispatchAllDomainEvents();
}
/**
* Example: Conditional event handling
*/
public function conditionalEventHandling(): void
{
$user = new ExampleUser('conditional@example.com', 'Conditional User');
// Record conditional domain event
$premiumEvent = new UserPremiumUpgradeEvent($user, 'premium_plan');
$this->entityManager->recordDomainEvent($user, $premiumEvent);
// Events will be dispatched based on conditions in handlers
$this->entityManager->insert($user);
$this->entityManager->dispatchDomainEventsForEntity($user);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events\Examples;
/**
* Example Entity for demonstration purposes
*/
final class ExampleUser
{
public readonly string $id;
public function __construct(
public readonly string $email,
public readonly string $name,
?string $id = null
) {
$this->id = $id ?? uniqid('user_');
}
public function updateEmail(string $newEmail): self
{
return new self($newEmail, $this->name, $this->id);
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Framework\Database\Events\Examples;
use App\Framework\Core\Events\OnEvent;
use App\Framework\Database\Events\EntityCreatedEvent;
use App\Framework\Database\Events\EntityDeletedEvent;
use App\Framework\Database\Events\EntityDetachedEvent;
use App\Framework\Database\Events\EntityLoadedEvent;
use App\Framework\Database\Events\EntityUpdatedEvent;
/**
* Example Event Handlers using Core EventDispatcher
*/
final class UserEventHandlers
{
/**
* Handle entity creation for all entities
*/
#[OnEvent(priority: 100)]
public function onEntityCreated(EntityCreatedEvent $event): void
{
error_log("Entity created: {$event->entityClass} with ID {$event->entityId}");
// Log to analytics service
$this->logAnalyticsEvent('entity_created', [
'entity_class' => $event->entityClass,
'entity_id' => $event->entityId,
'timestamp' => $event->timestamp,
]);
}
/**
* Handle user-specific entity creation
*/
#[OnEvent(priority: 90)]
public function onUserCreated(EntityCreatedEvent $event): void
{
if (! $event->isEntityOfType(ExampleUser::class)) {
return;
}
error_log("New user registered: {$event->entity->email}");
// Send welcome email, create user profile, etc.
$this->sendWelcomeEmail($event->entity);
$this->createUserProfile($event->entity);
$this->trackUserRegistration($event->entity);
}
/**
* Handle entity updates with change tracking
*/
#[OnEvent(priority: 100)]
public function onEntityUpdated(EntityUpdatedEvent $event): void
{
error_log("Entity updated: {$event->entityClass} with ID {$event->entityId}");
if ($event->isEntityOfType(ExampleUser::class)) {
$this->handleUserUpdate($event);
}
}
/**
* Handle entity deletion with cleanup
*/
#[OnEvent(priority: 100)]
public function onEntityDeleted(EntityDeletedEvent $event): void
{
error_log("Entity deleted: {$event->entityClass} with ID {$event->entityId}");
if ($event->isEntityOfType(ExampleUser::class)) {
$this->cleanupUserData($event->entity);
$this->notifyUserDeletion($event->entity);
}
}
/**
* Handle entity loading for performance monitoring
*/
#[OnEvent(priority: 50)]
public function onEntityLoaded(EntityLoadedEvent $event): void
{
if ($event->wasLazy) {
error_log("Lazy entity loaded: {$event->entityClass} with ID {$event->entityId}");
}
// Track loading performance
$this->trackEntityLoadTime($event);
}
/**
* Handle entity detachment for cleanup
*/
#[OnEvent(priority: 100)]
public function onEntityDetached(EntityDetachedEvent $event): void
{
error_log("Entity detached: {$event->entityClass} with ID {$event->entityId}");
// Clean up any cached references
$this->cleanupEntityReferences($event->entity);
}
/**
* Handle domain events
*/
#[OnEvent(priority: 200)]
public function onUserWelcomeEmail(UserWelcomeEmailEvent $event): void
{
error_log("Sending welcome email to: {$event->email}");
// Send actual email
// $this->emailService->sendWelcomeEmail($event->user, $event->email);
}
#[OnEvent(priority: 150)]
public function onUserRegistrationAnalytics(UserRegistrationAnalyticsEvent $event): void
{
error_log("Tracking user registration: {$event->source} - {$event->channel}");
// Track in analytics system
// $this->analyticsService->track('user_registered', [
// 'user_id' => $event->user->id,
// 'source' => $event->source,
// 'channel' => $event->channel,
// 'timestamp' => $event->timestamp,
// ]);
}
/**
* Helper methods (implementation would depend on your services)
*/
private function logAnalyticsEvent(string $eventType, array $data): void
{
// Implementation depends on your analytics service
error_log("Analytics: {$eventType} - " . json_encode($data));
}
private function sendWelcomeEmail(ExampleUser $user): void
{
error_log("Sending welcome email to: {$user->email}");
}
private function createUserProfile(ExampleUser $user): void
{
error_log("Creating user profile for: {$user->name}");
}
private function trackUserRegistration(ExampleUser $user): void
{
error_log("Tracking registration for user: {$user->id}");
}
private function handleUserUpdate(EntityUpdatedEvent $event): void
{
error_log("User updated: {$event->entity->email}");
// Handle specific field changes
if ($event->hasFieldChanged('email')) {
$oldEmail = $event->getOldValue('email');
$newEmail = $event->getNewValue('email');
error_log("Email changed from {$oldEmail} to {$newEmail}");
// Send email confirmation, update related records, etc.
}
}
private function cleanupUserData(ExampleUser $user): void
{
error_log("Cleaning up data for deleted user: {$user->id}");
// Delete user files, clear caches, notify related services
}
private function notifyUserDeletion(ExampleUser $user): void
{
error_log("Notifying services about user deletion: {$user->id}");
}
private function trackEntityLoadTime(EntityLoadedEvent $event): void
{
error_log("Entity load tracked: {$event->entityClass} loaded at {$event->timestamp}");
}
private function cleanupEntityReferences(object $entity): void
{
error_log("Cleaning up references for detached entity: " . get_class($entity));
}
}

Some files were not shown because too many files have changed in this diff Show More