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:
115
src/Framework/Database/AsyncAwareConnection.php
Normal file
115
src/Framework/Database/AsyncAwareConnection.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
335
src/Framework/Database/AsyncDatabaseAdapter.php
Normal file
335
src/Framework/Database/AsyncDatabaseAdapter.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
278
src/Framework/Database/AsyncDatabaseBuilder.php
Normal file
278
src/Framework/Database/AsyncDatabaseBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
463
src/Framework/Database/AsyncDatabaseDecorator.php
Normal file
463
src/Framework/Database/AsyncDatabaseDecorator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ final readonly class Type
|
||||
public ?string $foreignKey = null,
|
||||
public ?string $localKey = null,
|
||||
public string $type = 'hasMany'
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
130
src/Framework/Database/Backup/BackupMetadata.php
Normal file
130
src/Framework/Database/Backup/BackupMetadata.php
Normal 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'])
|
||||
);
|
||||
}
|
||||
}
|
||||
181
src/Framework/Database/Backup/BackupOptions.php
Normal file
181
src/Framework/Database/Backup/BackupOptions.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/Framework/Database/Backup/BackupResult.php
Normal file
39
src/Framework/Database/Backup/BackupResult.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/Framework/Database/Backup/BackupRetentionPolicy.php
Normal file
76
src/Framework/Database/Backup/BackupRetentionPolicy.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
330
src/Framework/Database/Backup/Console/BackupCommand.php
Normal file
330
src/Framework/Database/Backup/Console/BackupCommand.php
Normal 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();
|
||||
}
|
||||
}
|
||||
409
src/Framework/Database/Backup/DatabaseBackupService.php
Normal file
409
src/Framework/Database/Backup/DatabaseBackupService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
305
src/Framework/Database/BatchRelationLoader.php
Normal file
305
src/Framework/Database/BatchRelationLoader.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
330
src/Framework/Database/Cache/CacheMetrics.php
Normal file
330
src/Framework/Database/Cache/CacheMetrics.php
Normal 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);
|
||||
}
|
||||
}
|
||||
134
src/Framework/Database/Cache/CacheRegion.php
Normal file
134
src/Framework/Database/Cache/CacheRegion.php
Normal 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);
|
||||
}
|
||||
}
|
||||
245
src/Framework/Database/Cache/CacheWarmupStrategy.php
Normal file
245
src/Framework/Database/Cache/CacheWarmupStrategy.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
133
src/Framework/Database/Cache/EntityCacheKey.php
Normal file
133
src/Framework/Database/Cache/EntityCacheKey.php
Normal 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();
|
||||
}
|
||||
}
|
||||
339
src/Framework/Database/Cache/EntityCacheManager.php
Normal file
339
src/Framework/Database/Cache/EntityCacheManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
100
src/Framework/Database/Cache/NullSecondLevelCache.php
Normal file
100
src/Framework/Database/Cache/NullSecondLevelCache.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
345
src/Framework/Database/Cache/QueryCache.php
Normal file
345
src/Framework/Database/Cache/QueryCache.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
387
src/Framework/Database/Cache/RedisSecondLevelCache.php
Normal file
387
src/Framework/Database/Cache/RedisSecondLevelCache.php
Normal 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);
|
||||
}
|
||||
}
|
||||
82
src/Framework/Database/Cache/SecondLevelCacheFactory.php
Normal file
82
src/Framework/Database/Cache/SecondLevelCacheFactory.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/Framework/Database/Cache/SecondLevelCacheInitializer.php
Normal file
53
src/Framework/Database/Cache/SecondLevelCacheInitializer.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
93
src/Framework/Database/Cache/SecondLevelCacheInterface.php
Normal file
93
src/Framework/Database/Cache/SecondLevelCacheInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
src/Framework/Database/Cache/ValueObjects/EntityMetrics.php
Normal file
52
src/Framework/Database/Cache/ValueObjects/EntityMetrics.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
src/Framework/Database/Cache/ValueObjects/QueryMetrics.php
Normal file
33
src/Framework/Database/Cache/ValueObjects/QueryMetrics.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
46
src/Framework/Database/Cache/ValueObjects/RegionMetrics.php
Normal file
46
src/Framework/Database/Cache/ValueObjects/RegionMetrics.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
379
src/Framework/Database/Commands/DatabaseOptimizeCommand.php
Normal file
379
src/Framework/Database/Commands/DatabaseOptimizeCommand.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
151
src/Framework/Database/Commands/ShowProfilingStatsCommand.php
Normal file
151
src/Framework/Database/Commands/ShowProfilingStatsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
120
src/Framework/Database/Config/CacheConfig.php
Normal file
120
src/Framework/Database/Config/CacheConfig.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
50
src/Framework/Database/Config/DatabaseConfigInitializer.php
Normal file
50
src/Framework/Database/Config/DatabaseConfigInitializer.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/Framework/Database/Config/LoadBalancingStrategy.php
Normal file
17
src/Framework/Database/Config/LoadBalancingStrategy.php
Normal 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';
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
65
src/Framework/Database/Config/ReadWriteConfig.php
Normal file
65
src/Framework/Database/Config/ReadWriteConfig.php
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/Framework/Database/ConnectionInitializer.php
Normal file
48
src/Framework/Database/ConnectionInitializer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database;
|
||||
|
||||
73
src/Framework/Database/ConnectionMetadata.php
Normal file
73
src/Framework/Database/ConnectionMetadata.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
18
src/Framework/Database/Contracts/AsyncCapable.php
Normal file
18
src/Framework/Database/Contracts/AsyncCapable.php
Normal 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;
|
||||
}
|
||||
61
src/Framework/Database/Criteria/Criteria.php
Normal file
61
src/Framework/Database/Criteria/Criteria.php
Normal 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;
|
||||
}
|
||||
117
src/Framework/Database/Criteria/CriteriaQuery.php
Normal file
117
src/Framework/Database/Criteria/CriteriaQuery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
21
src/Framework/Database/Criteria/Criterion.php
Normal file
21
src/Framework/Database/Criteria/Criterion.php
Normal 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;
|
||||
}
|
||||
93
src/Framework/Database/Criteria/DetachedCriteria.php
Normal file
93
src/Framework/Database/Criteria/DetachedCriteria.php
Normal 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;
|
||||
}
|
||||
}
|
||||
180
src/Framework/Database/Criteria/Example/CriteriaUsageExample.php
Normal file
180
src/Framework/Database/Criteria/Example/CriteriaUsageExample.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
35
src/Framework/Database/Criteria/Expression/InExpression.php
Normal file
35
src/Framework/Database/Criteria/Expression/InExpression.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
28
src/Framework/Database/Criteria/Expression/NotExpression.php
Normal file
28
src/Framework/Database/Criteria/Expression/NotExpression.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
34
src/Framework/Database/Criteria/Order.php
Normal file
34
src/Framework/Database/Criteria/Order.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
21
src/Framework/Database/Criteria/Projection.php
Normal file
21
src/Framework/Database/Criteria/Projection.php
Normal 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;
|
||||
}
|
||||
@@ -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})"] : [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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] : [];
|
||||
}
|
||||
}
|
||||
93
src/Framework/Database/Criteria/Projections.php
Normal file
93
src/Framework/Database/Criteria/Projections.php
Normal 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);
|
||||
}
|
||||
}
|
||||
186
src/Framework/Database/Criteria/Restrictions.php
Normal file
186
src/Framework/Database/Criteria/Restrictions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() ?? [];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Driver;
|
||||
|
||||
interface Driver
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Driver;
|
||||
|
||||
enum DriverType:string
|
||||
enum DriverType: string
|
||||
{
|
||||
case MYSQL = 'mysql';
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
358
src/Framework/Database/Driver/Optimization/MySQLOptimizer.php
Normal file
358
src/Framework/Database/Driver/Optimization/MySQLOptimizer.php
Normal 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) . '`';
|
||||
}
|
||||
}
|
||||
@@ -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) . '"';
|
||||
}
|
||||
}
|
||||
482
src/Framework/Database/Driver/Optimization/SQLiteOptimizer.php
Normal file
482
src/Framework/Database/Driver/Optimization/SQLiteOptimizer.php
Normal 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) . '"';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
23
src/Framework/Database/EntityLoaderInterface.php
Normal file
23
src/Framework/Database/EntityLoaderInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
119
src/Framework/Database/Events/DomainEventCollector.php
Normal file
119
src/Framework/Database/Events/DomainEventCollector.php
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/Framework/Database/Events/EntityCreatedEvent.php
Normal file
47
src/Framework/Database/Events/EntityCreatedEvent.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
47
src/Framework/Database/Events/EntityDeletedEvent.php
Normal file
47
src/Framework/Database/Events/EntityDeletedEvent.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
45
src/Framework/Database/Events/EntityDetachedEvent.php
Normal file
45
src/Framework/Database/Events/EntityDetachedEvent.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
158
src/Framework/Database/Events/EntityEventManager.php
Normal file
158
src/Framework/Database/Events/EntityEventManager.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
49
src/Framework/Database/Events/EntityLoadedEvent.php
Normal file
49
src/Framework/Database/Events/EntityLoadedEvent.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
74
src/Framework/Database/Events/EntityUpdatedEvent.php
Normal file
74
src/Framework/Database/Events/EntityUpdatedEvent.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
26
src/Framework/Database/Events/Examples/ExampleUser.php
Normal file
26
src/Framework/Database/Events/Examples/ExampleUser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
191
src/Framework/Database/Events/Examples/UserEventHandlers.php
Normal file
191
src/Framework/Database/Events/Examples/UserEventHandlers.php
Normal 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
Reference in New Issue
Block a user