feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Framework\Database\Indexing\IndexAnalyzer;
use App\Framework\Database\Indexing\ValueObjects\IndexName;
use App\Framework\Database\Indexing\ValueObjects\IndexType;
use App\Framework\Database\PdoConnection;
describe('IndexAnalyzer', function () {
beforeEach(function () {
// Create in-memory SQLite database for testing
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create test table with indexes
$pdo->exec('
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT,
created_at TEXT
)
');
$pdo->exec('CREATE INDEX idx_users_status ON users(status)');
$pdo->exec('CREATE INDEX idx_users_created_at ON users(created_at)');
$pdo->exec('CREATE INDEX idx_users_email_status ON users(email, status)');
$this->connection = new PdoConnection($pdo, 'sqlite');
$this->analyzer = new IndexAnalyzer($this->connection);
});
it('can get all indexes for a table', function () {
$indexes = $this->analyzer->getTableIndexes('users');
expect($indexes)->toBeArray();
expect(count($indexes))->toBeGreaterThan(0);
// Should have at least: PRIMARY, UNIQUE, and our custom indexes
$indexNames = array_column($indexes, 'name');
expect($indexNames)->toContain('idx_users_status');
expect($indexNames)->toContain('idx_users_created_at');
});
it('detects index usage in query', function () {
$sql = 'SELECT * FROM users WHERE status = "active"';
$analysis = $this->analyzer->analyzeQuery($sql);
expect($analysis)->toBeArray();
expect($analysis)->toHaveKey('query');
expect($analysis)->toHaveKey('indexes_used');
expect($analysis)->toHaveKey('key_type');
expect($analysis['query'])->toBe($sql);
});
it('detects composite index columns correctly', function () {
$indexes = $this->analyzer->getTableIndexes('users');
$compositeIndex = array_values(array_filter($indexes, function ($index) {
return $index['name'] === 'idx_users_email_status';
}))[0] ?? null;
expect($compositeIndex)->not->toBeNull();
expect($compositeIndex['columns'])->toBe(['email', 'status']);
});
it('identifies index types correctly', function () {
$indexes = $this->analyzer->getTableIndexes('users');
foreach ($indexes as $index) {
expect($index['type'])->toBeInstanceOf(IndexType::class);
}
});
it('marks unique indexes correctly', function () {
$indexes = $this->analyzer->getTableIndexes('users');
$uniqueIndex = array_values(array_filter($indexes, function ($index) {
return str_contains($index['name'], 'email');
}))[0] ?? null;
expect($uniqueIndex)->not->toBeNull();
expect($uniqueIndex['is_unique'])->toBeTrue();
});
it('analyzes queries without indexes', function () {
$sql = 'SELECT * FROM users WHERE name LIKE "%John%"';
$analysis = $this->analyzer->analyzeQuery($sql);
expect($analysis['indexes_used'])->toBeArray();
// SQLite might use table scan for LIKE queries
expect($analysis['key_type'])->toBeIn(['scan', 'ALL', 'index']);
});
});

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
use App\Framework\Database\Indexing\IndexMigrationGenerator;
use App\Framework\Database\Indexing\ValueObjects\IndexRecommendation;
use App\Framework\Database\Indexing\ValueObjects\IndexType;
use App\Framework\Database\Indexing\ValueObjects\RecommendationPriority;
use App\Framework\Database\PdoConnection;
describe('IndexMigrationGenerator', function () {
beforeEach(function () {
$pdo = new PDO('sqlite::memory:');
$connection = new PdoConnection($pdo, 'sqlite');
$this->generator = new IndexMigrationGenerator($connection);
});
it('generates migration for adding indexes', function () {
$recommendations = [
new IndexRecommendation(
tableName: 'users',
columns: ['email'],
indexType: IndexType::BTREE,
reason: 'Frequent WHERE email lookups',
priority: RecommendationPriority::HIGH,
estimatedSpeedup: 5.0,
affectedQueries: 100
),
new IndexRecommendation(
tableName: 'users',
columns: ['status', 'created_at'],
indexType: IndexType::BTREE,
reason: 'WHERE status + ORDER BY created_at',
priority: RecommendationPriority::CRITICAL,
estimatedSpeedup: 10.0,
affectedQueries: 250
)
];
$migration = $this->generator->generateAddIndexMigration($recommendations, 'users');
expect($migration)->toBeString();
expect($migration)->toContain('extends Migration');
expect($migration)->toContain('public function up(Schema $schema)');
expect($migration)->toContain('public function down(Schema $schema)');
expect($migration)->toContain('users');
expect($migration)->toContain('$table->index');
});
it('generates migration for removing indexes', function () {
$unusedIndexes = [
[
'index_name' => 'idx_users_old_column',
'columns' => ['old_column']
],
[
'index_name' => 'idx_users_deprecated',
'columns' => ['deprecated_field']
]
];
$migration = $this->generator->generateRemoveIndexMigration($unusedIndexes, 'users');
expect($migration)->toBeString();
expect($migration)->toContain('extends Migration');
expect($migration)->toContain('$table->dropIndex');
expect($migration)->toContain('idx_users_old_column');
expect($migration)->toContain('idx_users_deprecated');
});
it('generates optimization migration (add + remove)', function () {
$toAdd = [
new IndexRecommendation(
tableName: 'orders',
columns: ['customer_id', 'status'],
indexType: IndexType::BTREE,
reason: 'WHERE customer_id + status filter',
priority: RecommendationPriority::HIGH,
estimatedSpeedup: 8.0,
affectedQueries: 150
)
];
$toRemove = [
[
'index_name' => 'idx_orders_unused',
'columns' => ['unused_column']
]
];
$migration = $this->generator->generateOptimizationMigration($toAdd, $toRemove, 'orders');
expect($migration)->toBeString();
expect($migration)->toContain('extends Migration');
expect($migration)->toContain('$table->dropIndex(\'idx_orders_unused\')');
expect($migration)->toContain('$table->index');
});
it('includes proper up/down migration methods', function () {
$recommendations = [
new IndexRecommendation(
tableName: 'products',
columns: ['category'],
indexType: IndexType::BTREE,
reason: 'Category filtering',
priority: RecommendationPriority::MEDIUM,
estimatedSpeedup: 3.0,
affectedQueries: 50
)
];
$migration = $this->generator->generateAddIndexMigration($recommendations, 'products');
// Check UP method
expect($migration)->toContain('public function up(Schema $schema)');
expect($migration)->toContain('$schema->table(\'products\'');
// Check DOWN method (should reverse the UP actions)
expect($migration)->toContain('public function down(Schema $schema)');
expect($migration)->toContain('$table->dropIndex');
});
it('validates migration content structure', function () {
$recommendations = [
new IndexRecommendation(
tableName: 'test_table',
columns: ['test_column'],
indexType: IndexType::BTREE,
reason: 'Test',
priority: RecommendationPriority::LOW,
estimatedSpeedup: 1.5,
affectedQueries: 10
)
];
$migration = $this->generator->generateAddIndexMigration($recommendations, 'test_table');
// Validate required components
expect($migration)->toContain('declare(strict_types=1)');
expect($migration)->toContain('use App\Framework\Database\Migration\Migration');
expect($migration)->toContain('use App\Framework\Database\Schema\Schema');
expect($migration)->toContain('final class');
});
it('handles composite indexes correctly', function () {
$recommendations = [
new IndexRecommendation(
tableName: 'posts',
columns: ['author_id', 'published_at', 'status'],
indexType: IndexType::BTREE,
reason: 'Complex WHERE + ORDER BY',
priority: RecommendationPriority::CRITICAL,
estimatedSpeedup: 15.0,
affectedQueries: 300
)
];
$migration = $this->generator->generateAddIndexMigration($recommendations, 'posts');
expect($migration)->toContain('author_id');
expect($migration)->toContain('published_at');
expect($migration)->toContain('status');
});
});

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Framework\Database\Indexing\UnusedIndexDetector;
use App\Framework\Database\Indexing\IndexAnalyzer;
use App\Framework\Database\Indexing\IndexUsageTracker;
use App\Framework\Database\PdoConnection;
describe('UnusedIndexDetector', function () {
beforeEach(function () {
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE,
category TEXT,
price REAL,
stock INTEGER,
created_at TEXT
)
');
$pdo->exec('CREATE INDEX idx_products_category ON products(category)');
$pdo->exec('CREATE INDEX idx_products_price ON products(price)');
$pdo->exec('CREATE INDEX idx_products_stock ON products(stock)');
$pdo->exec('CREATE INDEX idx_products_category_price ON products(category, price)');
$connection = new PdoConnection($pdo, 'sqlite');
$analyzer = new IndexAnalyzer($connection);
$cache = new \App\Framework\Cache\Driver\InMemoryCache();
$usageTracker = new IndexUsageTracker($cache, $analyzer);
$this->detector = new UnusedIndexDetector($analyzer, $usageTracker);
$this->tableName = 'products';
});
it('finds unused indexes', function () {
$unusedIndexes = $this->detector->findUnusedIndexes($this->tableName);
expect($unusedIndexes)->toBeArray();
// Since we haven't tracked any usage, all non-unique indexes should be marked as unused
expect(count($unusedIndexes))->toBeGreaterThan(0);
});
it('detects duplicate indexes', function () {
// Create duplicate index in test
// (In real scenario, we'd have actual duplicates)
$duplicates = $this->detector->findDuplicateIndexes($this->tableName);
expect($duplicates)->toBeArray();
});
it('finds redundant indexes (prefix pattern)', function () {
$redundant = $this->detector->findRedundantIndexes($this->tableName);
expect($redundant)->toBeArray();
// idx_products_category is redundant with idx_products_category_price
// because category is a prefix of [category, price]
if (!empty($redundant)) {
expect($redundant[0])->toHaveKey('redundant_index');
expect($redundant[0])->toHaveKey('covered_by');
}
});
it('generates comprehensive unused index report', function () {
$report = $this->detector->getUnusedIndexReport($this->tableName);
expect($report)->toHaveKey('unused');
expect($report)->toHaveKey('duplicates');
expect($report)->toHaveKey('redundant');
expect($report)->toHaveKey('total_removable');
expect($report)->toHaveKey('estimated_space_savings');
expect($report['unused'])->toBeArray();
expect($report['total_removable'])->toBeInt();
});
it('generates DROP statements for unused indexes', function () {
$statements = $this->detector->generateDropStatements($this->tableName);
expect($statements)->toBeArray();
foreach ($statements as $statement) {
expect($statement)->toContain('DROP INDEX');
expect($statement)->toContain('ON products');
}
});
it('never suggests dropping PRIMARY or UNIQUE indexes', function () {
$unusedIndexes = $this->detector->findUnusedIndexes($this->tableName);
// Filter for any PRIMARY or UNIQUE suggestions
$primaryOrUnique = array_filter($unusedIndexes, function ($index) {
return str_contains(strtolower($index['index_name']), 'primary') ||
str_contains(strtolower($index['index_name']), 'unique');
});
expect($primaryOrUnique)->toBeEmpty();
});
it('estimates space savings correctly', function () {
$report = $this->detector->getUnusedIndexReport($this->tableName);
expect($report['estimated_space_savings'])->toBeString();
expect($report['estimated_space_savings'])->toMatch('/(MB|GB|< 1 MB)/');
});
});

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
use App\Framework\Database\QueryOptimization\Analysis\EagerLoadingAnalyzer;
use App\Framework\Database\QueryOptimization\Analysis\NPlusOneDetector;
use App\Framework\Database\QueryOptimization\NPlusOneDetectionService;
use App\Framework\Database\QueryOptimization\QueryLogger;
use App\Framework\Database\QueryOptimization\ValueObjects\QueryLog;
use App\Framework\Logging\Logger;
describe('NPlusOneDetectionService', function () {
beforeEach(function () {
$this->queryLogger = new QueryLogger();
$this->detector = new NPlusOneDetector();
$this->analyzer = new EagerLoadingAnalyzer();
$this->logger = $this->createMock(Logger::class);
$this->service = new NPlusOneDetectionService(
$this->queryLogger,
$this->detector,
$this->analyzer,
$this->logger
);
});
it('starts and stops logging', function () {
expect($this->queryLogger->isEnabled())->toBeFalse();
$this->service->startLogging();
expect($this->queryLogger->isEnabled())->toBeTrue();
$this->service->stopLogging();
expect($this->queryLogger->isEnabled())->toBeFalse();
});
it('analyzes logged queries', function () {
// Enable logging
$this->service->startLogging();
// Simulate N+1 queries
for ($i = 1; $i <= 10; $i++) {
$this->queryLogger->logQuery(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
rowCount: 1
);
}
// Analyze
$result = $this->service->analyze();
expect($result)->toHaveKeys(['detections', 'strategies', 'statistics']);
expect($result['detections'])->not->toBeEmpty();
expect($result['statistics']['total_queries'])->toBe(10);
});
it('generates formatted report', function () {
$this->service->startLogging();
// Simulate N+1 queries
for ($i = 1; $i <= 10; $i++) {
$this->queryLogger->logQuery(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
rowCount: 1
);
}
$report = $this->service->analyzeAndReport();
expect($report)->toContain('N+1 Query Detection Report');
expect($report)->toContain('Query Statistics');
expect($report)->toContain('N+1 Problems Detected');
});
it('profiles code execution', function () {
$executedCallable = false;
$analysis = $this->service->profile(function () use (&$executedCallable) {
$executedCallable = true;
return 'result';
});
expect($executedCallable)->toBeTrue();
expect($analysis)->toHaveKey('execution_time_ms');
expect($analysis)->toHaveKey('callback_result');
expect($analysis['callback_result'])->toBe('result');
});
it('returns critical problems only', function () {
$this->service->startLogging();
// Add critical N+1 (high execution count)
for ($i = 1; $i <= 50; $i++) {
$this->queryLogger->logQuery(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 10.0,
rowCount: 1
);
}
// Add low severity N+1
for ($i = 1; $i <= 6; $i++) {
$this->queryLogger->logQuery(
sql: 'SELECT * FROM comments WHERE post_id = ?',
bindings: [$i],
executionTimeMs: 1.0,
rowCount: 1
);
}
$critical = $this->service->getCriticalProblems();
// Should only contain the high-severity posts pattern
expect($critical)->toHaveCount(1);
expect($critical[0]->isCritical())->toBeTrue();
});
it('quick check detects N+1 problems', function () {
$this->service->startLogging();
for ($i = 1; $i <= 10; $i++) {
$this->queryLogger->logQuery(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
rowCount: 1
);
}
expect($this->service->hasNPlusOneProblems())->toBeTrue();
});
it('returns query statistics', function () {
$this->service->startLogging();
$this->queryLogger->logQuery('SELECT * FROM users', [], 5.0, 10);
$this->queryLogger->logQuery('INSERT INTO logs (message) VALUES (?)', ['test'], 2.0, 1);
$this->queryLogger->logQuery('UPDATE users SET name = ? WHERE id = ?', ['John', 1], 3.0, 1);
$stats = $this->service->getQueryStatistics();
expect($stats['total_queries'])->toBe(3);
expect($stats['select_count'])->toBe(1);
expect($stats['insert_count'])->toBe(1);
expect($stats['update_count'])->toBe(1);
});
it('clears query logs', function () {
$this->service->startLogging();
$this->queryLogger->logQuery('SELECT * FROM users', [], 5.0);
expect($this->service->getQueryLogs())->toHaveCount(1);
$this->service->clearLogs();
expect($this->service->getQueryLogs())->toBeEmpty();
});
it('integrates detections with eager loading strategies', function () {
$this->service->startLogging();
// Create N+1 pattern
for ($i = 1; $i <= 15; $i++) {
$this->queryLogger->logQuery(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 8.0,
rowCount: 1
);
}
$result = $this->service->analyze();
expect($result['detections'])->toHaveCount(1);
expect($result['strategies'])->toHaveCount(1);
$strategy = $result['strategies'][0];
expect($strategy->tableName)->toBe('posts');
expect($strategy->codeExample)->toContain('eager loading');
});
it('handles empty query logs gracefully', function () {
$this->service->startLogging();
$result = $this->service->analyze();
expect($result['detections'])->toBeEmpty();
expect($result['strategies'])->toBeEmpty();
expect($result['statistics']['total_queries'])->toBe(0);
});
it('logs analysis completion', function () {
$this->logger->expects($this->once())
->method('info')
->with(
$this->stringContains('Analysis completed'),
$this->anything()
);
$this->service->startLogging();
$this->queryLogger->logQuery('SELECT * FROM users', [], 5.0);
$this->service->analyze();
});
});

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
use App\Framework\Database\QueryOptimization\Analysis\NPlusOneDetector;
use App\Framework\Database\QueryOptimization\ValueObjects\QueryLog;
describe('NPlusOneDetector', function () {
it('detects N+1 pattern with multiple executions', function () {
$detector = new NPlusOneDetector(minExecutionCount: 5, minSeverityScore: 4.0);
$queryLogs = [];
// 10 queries with same pattern
for ($i = 1; $i <= 10; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 8.0,
stackTrace: 'UserController::index',
callerClass: 'UserController',
callerMethod: 'index',
callerLine: 42
);
}
$detections = $detector->analyze($queryLogs);
expect($detections)->toHaveCount(1);
expect($detections[0]->pattern->getTableName())->toBe('posts');
expect($detections[0]->pattern->getExecutionCount())->toBe(10);
});
it('ignores patterns with too few executions', function () {
$detector = new NPlusOneDetector(minExecutionCount: 10);
$queryLogs = [];
// Only 5 queries
for ($i = 1; $i <= 5; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: ''
);
}
$detections = $detector->analyze($queryLogs);
expect($detections)->toBeEmpty();
});
it('ignores patterns with low severity', function () {
$detector = new NPlusOneDetector(minExecutionCount: 5, minSeverityScore: 8.0);
$queryLogs = [];
// 6 queries with low severity
for ($i = 1; $i <= 6; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 1.0,
stackTrace: '',
callerClass: 'Different' . $i,
callerMethod: 'method' . $i
);
}
$detections = $detector->analyze($queryLogs);
expect($detections)->toBeEmpty();
});
it('generates recommendations for detected N+1', function () {
$detector = new NPlusOneDetector();
$queryLogs = [];
for ($i = 1; $i <= 10; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: 'UserController::index',
callerClass: 'UserController',
callerMethod: 'index',
callerLine: 42
);
}
$detections = $detector->analyze($queryLogs);
expect($detections[0]->recommendation)->toContain('eager loading');
expect($detections[0]->recommendation)->toContain('batch loading');
expect($detections[0]->recommendation)->toContain('JOIN');
});
it('detects multiple N+1 patterns', function () {
$detector = new NPlusOneDetector();
$queryLogs = [];
// Pattern 1: Posts
for ($i = 1; $i <= 10; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: '',
callerClass: 'UserController',
callerMethod: 'index'
);
}
// Pattern 2: Comments
for ($i = 1; $i <= 8; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM comments WHERE post_id = ?',
bindings: [$i],
executionTimeMs: 3.0,
stackTrace: '',
callerClass: 'PostController',
callerMethod: 'show'
);
}
$detections = $detector->analyze($queryLogs);
expect($detections)->toHaveCount(2);
$tables = array_map(fn($d) => $d->pattern->getTableName(), $detections);
expect($tables)->toContain('posts');
expect($tables)->toContain('comments');
});
it('calculates statistics correctly', function () {
$detector = new NPlusOneDetector();
$queryLogs = [];
// 10 N+1 queries
for ($i = 1; $i <= 10; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 10.0,
stackTrace: '',
callerClass: 'UserController',
callerMethod: 'index'
);
}
// 5 normal queries
for ($i = 1; $i <= 5; $i++) {
$queryLogs[] = new QueryLog(
sql: 'INSERT INTO logs (message) VALUES (?)',
bindings: ["log{$i}"],
executionTimeMs: 2.0,
stackTrace: ''
);
}
$stats = $detector->getStatistics($queryLogs);
expect($stats['total_queries'])->toBe(15);
expect($stats['n_plus_one_patterns'])->toBe(1);
expect($stats['n_plus_one_queries'])->toBe(10);
expect($stats['n_plus_one_time_ms'])->toBe(100.0);
expect($stats['n_plus_one_percentage'])->toBeGreaterThan(50.0);
});
it('quick check detects N+1 problems', function () {
$detector = new NPlusOneDetector();
$queryLogs = [];
for ($i = 1; $i <= 10; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: '',
callerClass: 'UserController',
callerMethod: 'index'
);
}
expect($detector->hasNPlusOne($queryLogs))->toBeTrue();
});
it('quick check returns false for no N+1', function () {
$detector = new NPlusOneDetector();
$queryLogs = [
new QueryLog('SELECT * FROM users', [], 5.0, ''),
new QueryLog('SELECT * FROM posts WHERE id = ?', [1], 3.0, ''),
];
expect($detector->hasNPlusOne($queryLogs))->toBeFalse();
});
it('generates metadata with query details', function () {
$detector = new NPlusOneDetector();
$queryLogs = [];
for ($i = 1; $i <= 10; $i++) {
$queryLogs[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: '',
callerClass: 'UserController',
callerMethod: 'index'
);
}
$detections = $detector->analyze($queryLogs);
expect($detections[0]->metadata)->toHaveKey('execution_count');
expect($detections[0]->metadata)->toHaveKey('total_time_ms');
expect($detections[0]->metadata)->toHaveKey('average_time_ms');
expect($detections[0]->metadata)->toHaveKey('table_name');
expect($detections[0]->metadata)->toHaveKey('first_query_sql');
});
});

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
use App\Framework\Database\QueryOptimization\ValueObjects\QueryLog;
describe('QueryLog Value Object', function () {
it('creates query log with all properties', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users WHERE id = ?',
bindings: [123],
executionTimeMs: 5.5,
stackTrace: 'UserController::index',
rowCount: 1,
callerClass: 'UserController',
callerMethod: 'index',
callerLine: 42
);
expect($log->sql)->toBe('SELECT * FROM users WHERE id = ?');
expect($log->bindings)->toBe([123]);
expect($log->executionTimeMs)->toBe(5.5);
expect($log->rowCount)->toBe(1);
expect($log->callerClass)->toBe('UserController');
expect($log->callerMethod)->toBe('index');
expect($log->callerLine)->toBe(42);
});
it('normalizes SQL pattern correctly', function () {
$log = new QueryLog(
sql: "SELECT * FROM users WHERE id = 123 AND email = 'test@example.com'",
bindings: [],
executionTimeMs: 1.0,
stackTrace: ''
);
$pattern = $log->getPattern();
expect($pattern)->toBe('SELECT * FROM users WHERE id = ? AND email = ?');
});
it('normalizes IN clauses', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5)',
bindings: [],
executionTimeMs: 1.0,
stackTrace: ''
);
$pattern = $log->getPattern();
expect($pattern)->toBe('SELECT * FROM users WHERE id IN (?)');
});
it('detects SELECT queries', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users',
bindings: [],
executionTimeMs: 1.0,
stackTrace: ''
);
expect($log->isSelect())->toBeTrue();
expect($log->isInsert())->toBeFalse();
expect($log->isUpdate())->toBeFalse();
expect($log->isDelete())->toBeFalse();
});
it('detects INSERT queries', function () {
$log = new QueryLog(
sql: 'INSERT INTO users (name, email) VALUES (?, ?)',
bindings: ['John', 'john@example.com'],
executionTimeMs: 2.0,
stackTrace: ''
);
expect($log->isInsert())->toBeTrue();
expect($log->isSelect())->toBeFalse();
});
it('extracts table name from SELECT', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users WHERE id = ?',
bindings: [1],
executionTimeMs: 1.0,
stackTrace: ''
);
expect($log->getTableName())->toBe('users');
});
it('extracts table name from INSERT', function () {
$log = new QueryLog(
sql: 'INSERT INTO orders (user_id, total) VALUES (?, ?)',
bindings: [1, 100],
executionTimeMs: 1.0,
stackTrace: ''
);
expect($log->getTableName())->toBe('orders');
});
it('detects WHERE clause', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users WHERE id = ?',
bindings: [1],
executionTimeMs: 1.0,
stackTrace: ''
);
expect($log->hasWhereClause())->toBeTrue();
});
it('detects missing WHERE clause', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users',
bindings: [],
executionTimeMs: 1.0,
stackTrace: ''
);
expect($log->hasWhereClause())->toBeFalse();
});
it('detects JOIN queries', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users LEFT JOIN profiles ON users.id = profiles.user_id',
bindings: [],
executionTimeMs: 1.0,
stackTrace: ''
);
expect($log->hasJoin())->toBeTrue();
});
it('detects slow queries', function () {
$log = new QueryLog(
sql: 'SELECT * FROM users',
bindings: [],
executionTimeMs: 150.0,
stackTrace: ''
);
expect($log->isSlow(100.0))->toBeTrue();
expect($log->isSlow(200.0))->toBeFalse();
});
it('throws on negative execution time', function () {
new QueryLog(
sql: 'SELECT * FROM users',
bindings: [],
executionTimeMs: -5.0,
stackTrace: ''
);
})->throws(InvalidArgumentException::class, 'Execution time cannot be negative');
it('throws on negative row count', function () {
new QueryLog(
sql: 'SELECT * FROM users',
bindings: [],
executionTimeMs: 1.0,
stackTrace: '',
rowCount: -1
);
})->throws(InvalidArgumentException::class, 'Row count cannot be negative');
});

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
use App\Framework\Database\QueryOptimization\QueryLogger;
describe('QueryLogger', function () {
beforeEach(function () {
$this->logger = new QueryLogger();
});
it('starts disabled by default', function () {
expect($this->logger->isEnabled())->toBeFalse();
});
it('enables and disables logging', function () {
$this->logger->enable();
expect($this->logger->isEnabled())->toBeTrue();
$this->logger->disable();
expect($this->logger->isEnabled())->toBeFalse();
});
it('ignores queries when disabled', function () {
$this->logger->disable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
expect($this->logger->getQueryCount())->toBe(0);
});
it('logs queries when enabled', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users WHERE id = ?', [1], 5.5, 1);
expect($this->logger->getQueryCount())->toBe(1);
$logs = $this->logger->getQueryLogs();
expect($logs)->toHaveCount(1);
expect($logs[0]->sql)->toBe('SELECT * FROM users WHERE id = ?');
expect($logs[0]->bindings)->toBe([1]);
expect($logs[0]->executionTimeMs)->toBe(5.5);
expect($logs[0]->rowCount)->toBe(1);
});
it('captures stack trace', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
$logs = $this->logger->getQueryLogs();
expect($logs[0]->stackTrace)->not->toBeEmpty();
});
it('identifies caller class and method', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
$logs = $this->logger->getQueryLogs();
expect($logs[0]->callerClass)->not->toBeNull();
expect($logs[0]->callerMethod)->not->toBeNull();
});
it('calculates total execution time', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
$this->logger->logQuery('SELECT * FROM posts', [], 10.0);
$this->logger->logQuery('SELECT * FROM comments', [], 3.5);
expect($this->logger->getTotalExecutionTime())->toBe(18.5);
});
it('groups queries by pattern', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users WHERE id = ?', [1], 5.0);
$this->logger->logQuery('SELECT * FROM users WHERE id = ?', [2], 4.5);
$this->logger->logQuery('SELECT * FROM posts WHERE user_id = ?', [1], 3.0);
$grouped = $this->logger->getGroupedByPattern();
expect($grouped)->toHaveCount(2);
expect($grouped['SELECT * FROM users WHERE id = ?'])->toHaveCount(2);
expect($grouped['SELECT * FROM posts WHERE user_id = ?'])->toHaveCount(1);
});
it('identifies slow queries', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 50.0);
$this->logger->logQuery('SELECT * FROM posts', [], 150.0);
$this->logger->logQuery('SELECT * FROM comments', [], 5.0);
$slowQueries = $this->logger->getSlowQueries(100.0);
expect($slowQueries)->toHaveCount(1);
expect($slowQueries[0]->sql)->toContain('posts');
expect($slowQueries[0]->executionTimeMs)->toBe(150.0);
});
it('clears logged queries', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
$this->logger->logQuery('SELECT * FROM posts', [], 3.0);
expect($this->logger->getQueryCount())->toBe(2);
$this->logger->clear();
expect($this->logger->getQueryCount())->toBe(0);
expect($this->logger->getQueryLogs())->toBeEmpty();
});
it('generates query statistics', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
$this->logger->logQuery('SELECT * FROM posts', [], 10.0);
$this->logger->logQuery('INSERT INTO logs (message) VALUES (?)', ['test'], 2.0);
$this->logger->logQuery('UPDATE users SET name = ? WHERE id = ?', ['John', 1], 3.0);
$this->logger->logQuery('DELETE FROM logs WHERE id = ?', [1], 1.5);
$this->logger->logQuery('SELECT * FROM users', [], 150.0); // Slow query
$stats = $this->logger->getStatistics();
expect($stats['total_queries'])->toBe(6);
expect($stats['total_time_ms'])->toBe(171.5);
expect($stats['select_count'])->toBe(3);
expect($stats['insert_count'])->toBe(1);
expect($stats['update_count'])->toBe(1);
expect($stats['delete_count'])->toBe(1);
expect($stats['slow_queries'])->toBe(1);
expect($stats['unique_patterns'])->toBe(5);
});
it('calculates average execution time', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 10.0);
$this->logger->logQuery('SELECT * FROM posts', [], 20.0);
$this->logger->logQuery('SELECT * FROM comments', [], 30.0);
$stats = $this->logger->getStatistics();
expect($stats['average_time_ms'])->toBe(20.0);
});
it('handles empty query logs in statistics', function () {
$this->logger->enable();
$stats = $this->logger->getStatistics();
expect($stats['total_queries'])->toBe(0);
expect($stats['average_time_ms'])->toBe(0.0);
});
it('skips framework internals when finding caller', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
$logs = $this->logger->getQueryLogs();
// Caller should not be from /Framework/Database/ or /vendor/
if ($logs[0]->callerClass) {
expect($logs[0]->callerClass)->not->toContain('Framework\\Database');
expect($logs[0]->callerClass)->not->toContain('vendor');
}
});
it('formats stack trace correctly', function () {
$this->logger->enable();
$this->logger->logQuery('SELECT * FROM users', [], 5.0);
$logs = $this->logger->getQueryLogs();
$stackTrace = $logs[0]->stackTrace;
// Stack trace should contain class/method and file:line format
expect($stackTrace)->toMatch('/.+\(.+:\d+\)/');
});
});

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
use App\Framework\Database\QueryOptimization\ValueObjects\QueryLog;
use App\Framework\Database\QueryOptimization\ValueObjects\QueryPattern;
describe('QueryPattern Value Object', function () {
it('groups queries by pattern', function () {
$queries = [
new QueryLog('SELECT * FROM users WHERE id = ?', [1], 5.0, ''),
new QueryLog('SELECT * FROM users WHERE id = ?', [2], 4.5, ''),
new QueryLog('SELECT * FROM users WHERE id = ?', [3], 6.0, ''),
];
$pattern = new QueryPattern('SELECT * FROM users WHERE id = ?', $queries);
expect($pattern->getExecutionCount())->toBe(3);
expect($pattern->getTotalExecutionTimeMs())->toBe(15.5);
expect($pattern->getAverageExecutionTimeMs())->toBe(5.166666666666667);
});
it('detects potential N+1 pattern', function () {
$queries = [];
for ($i = 1; $i <= 10; $i++) {
$queries[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: 'UserController::show'
);
}
$pattern = new QueryPattern('SELECT * FROM posts WHERE user_id = ?', $queries);
expect($pattern->isPotentialNPlusOne())->toBeTrue();
});
it('rejects pattern with too few executions', function () {
$queries = [
new QueryLog('SELECT * FROM users WHERE id = ?', [1], 5.0, ''),
new QueryLog('SELECT * FROM users WHERE id = ?', [2], 5.0, ''),
];
$pattern = new QueryPattern('SELECT * FROM users WHERE id = ?', $queries);
expect($pattern->isPotentialNPlusOne())->toBeFalse();
});
it('rejects pattern without WHERE clause', function () {
$queries = [];
for ($i = 1; $i <= 10; $i++) {
$queries[] = new QueryLog(
sql: 'SELECT * FROM users',
bindings: [],
executionTimeMs: 5.0,
stackTrace: ''
);
}
$pattern = new QueryPattern('SELECT * FROM users', $queries);
expect($pattern->isPotentialNPlusOne())->toBeFalse();
});
it('rejects pattern with JOIN', function () {
$queries = [];
for ($i = 1; $i <= 10; $i++) {
$queries[] = new QueryLog(
sql: 'SELECT * FROM users LEFT JOIN posts ON users.id = posts.user_id WHERE users.id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: ''
);
}
$pattern = new QueryPattern(
'SELECT * FROM users LEFT JOIN posts ON users.id = posts.user_id WHERE users.id = ?',
$queries
);
expect($pattern->isPotentialNPlusOne())->toBeFalse();
});
it('calculates N+1 severity correctly', function () {
$queries = [];
// High execution count (20 queries)
for ($i = 1; $i <= 20; $i++) {
$queries[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 10.0,
stackTrace: 'UserController::show',
callerClass: 'UserController',
callerMethod: 'show'
);
}
$pattern = new QueryPattern('SELECT * FROM posts WHERE user_id = ?', $queries);
$severity = $pattern->getNPlusOneSeverity();
// Should be high severity (execution count > 10, consistent caller, high total time)
expect($severity)->toBeGreaterThan(6);
});
it('classifies severity levels correctly', function () {
$highSeverityQueries = [];
for ($i = 1; $i <= 50; $i++) {
$highSeverityQueries[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 15.0,
stackTrace: 'UserController::show',
callerClass: 'UserController',
callerMethod: 'show'
);
}
$pattern = new QueryPattern('SELECT * FROM posts WHERE user_id = ?', $highSeverityQueries);
expect($pattern->getSeverityLevel())->toBeIn(['CRITICAL', 'HIGH']);
});
it('identifies table name', function () {
$queries = [
new QueryLog('SELECT * FROM users WHERE id = ?', [1], 5.0, ''),
];
$pattern = new QueryPattern('SELECT * FROM users WHERE id = ?', $queries);
expect($pattern->getTableName())->toBe('users');
});
it('checks for consistent caller', function () {
$queries = [];
for ($i = 1; $i <= 10; $i++) {
$queries[] = new QueryLog(
sql: 'SELECT * FROM posts WHERE user_id = ?',
bindings: [$i],
executionTimeMs: 5.0,
stackTrace: '',
callerClass: 'UserController',
callerMethod: 'show'
);
}
$pattern = new QueryPattern('SELECT * FROM posts WHERE user_id = ?', $queries);
expect($pattern->hasConsistentCaller())->toBeTrue();
});
it('detects inconsistent caller', function () {
$queries = [
new QueryLog('SELECT * FROM posts WHERE user_id = ?', [1], 5.0, '', callerClass: 'UserController', callerMethod: 'show'),
new QueryLog('SELECT * FROM posts WHERE user_id = ?', [2], 5.0, '', callerClass: 'PostController', callerMethod: 'index'),
new QueryLog('SELECT * FROM posts WHERE user_id = ?', [3], 5.0, '', callerClass: 'UserController', callerMethod: 'show'),
];
$pattern = new QueryPattern('SELECT * FROM posts WHERE user_id = ?', $queries);
expect($pattern->hasConsistentCaller())->toBeFalse();
});
it('gets caller locations', function () {
$queries = [
new QueryLog('SELECT * FROM posts WHERE user_id = ?', [1], 5.0, '', callerClass: 'UserController', callerMethod: 'show', callerLine: 42),
new QueryLog('SELECT * FROM posts WHERE user_id = ?', [2], 5.0, '', callerClass: 'UserController', callerMethod: 'show', callerLine: 42),
];
$pattern = new QueryPattern('SELECT * FROM posts WHERE user_id = ?', $queries);
$locations = $pattern->getCallerLocations();
expect($locations)->toContain('UserController::show:42');
});
});