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,328 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Database\NPlusOneDetection\MachineLearning\Extractors;
use App\Framework\Database\NPlusOneDetection\MachineLearning\Extractors\QueryFeatureExtractor;
use App\Framework\Database\NPlusOneDetection\QueryExecutionContext;
use App\Framework\MachineLearning\ValueObjects\FeatureType;
use App\Framework\Core\ValueObjects\Duration;
describe('QueryFeatureExtractor', function () {
it('is enabled by default', function () {
$extractor = new QueryFeatureExtractor();
expect($extractor->isEnabled())->toBeTrue();
});
it('can be disabled via constructor', function () {
$extractor = new QueryFeatureExtractor(enabled: false);
expect($extractor->isEnabled())->toBeFalse();
});
it('returns FREQUENCY as feature type', function () {
$extractor = new QueryFeatureExtractor();
expect($extractor->getFeatureType())->toBe(FeatureType::FREQUENCY);
});
it('has default priority of 10', function () {
$extractor = new QueryFeatureExtractor();
expect($extractor->getPriority())->toBe(10);
});
it('can extract from QueryExecutionContext', function () {
$extractor = new QueryFeatureExtractor();
$context = QueryExecutionContext::minimal();
expect($extractor->canExtract($context))->toBeTrue();
});
it('cannot extract from non-QueryExecutionContext', function () {
$extractor = new QueryFeatureExtractor();
expect($extractor->canExtract(['not' => 'context']))->toBeFalse();
expect($extractor->canExtract('string'))->toBeFalse();
expect($extractor->canExtract(null))->toBeFalse();
});
it('extracts 8 features from minimal context', function () {
$extractor = new QueryFeatureExtractor();
$context = QueryExecutionContext::minimal(
queryCount: 10,
durationMs: 100.0,
uniqueQueries: 5
);
$features = $extractor->extractFeatures($context);
expect($features)->toHaveCount(8);
// Verify all expected features are present
$featureNames = array_map(fn($f) => $f->name, $features);
expect($featureNames)->toContain('query_frequency');
expect($featureNames)->toContain('query_repetition_rate');
expect($featureNames)->toContain('avg_query_execution_time');
expect($featureNames)->toContain('timing_pattern_regularity');
expect($featureNames)->toContain('avg_query_complexity');
expect($featureNames)->toContain('avg_join_count');
expect($featureNames)->toContain('loop_execution_detected');
expect($featureNames)->toContain('query_similarity_score');
});
it('calculates query frequency correctly', function () {
$extractor = new QueryFeatureExtractor();
// 10 queries in 1 second = 10 queries/second
$context = QueryExecutionContext::minimal(
queryCount: 10,
durationMs: 1000.0,
uniqueQueries: 5
);
$features = $extractor->extractFeatures($context);
$frequencyFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'query_frequency'
))[0];
expect($frequencyFeature->value)->toBe(10.0);
expect($frequencyFeature->unit)->toBe('queries/second');
});
it('calculates query repetition rate correctly', function () {
$extractor = new QueryFeatureExtractor();
// 10 total queries, 3 unique = 70% repetition rate
$context = QueryExecutionContext::minimal(
queryCount: 10,
durationMs: 100.0,
uniqueQueries: 3
);
$features = $extractor->extractFeatures($context);
$repetitionFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'query_repetition_rate'
))[0];
expect($repetitionFeature->value)->toBe(70.0);
expect($repetitionFeature->unit)->toBe('percentage');
});
it('calculates average execution time correctly', function () {
$extractor = new QueryFeatureExtractor();
// 100ms total for 10 queries = 10ms average
$context = QueryExecutionContext::minimal(
queryCount: 10,
durationMs: 100.0,
uniqueQueries: 5
);
$features = $extractor->extractFeatures($context);
$avgTimeFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'avg_query_execution_time'
))[0];
expect($avgTimeFeature->value)->toBe(10.0);
expect($avgTimeFeature->unit)->toBe('milliseconds');
});
it('detects loop execution pattern', function () {
$extractor = new QueryFeatureExtractor();
$context = new QueryExecutionContext(
queryCount: 10,
duration: Duration::fromMilliseconds(100),
uniqueQueryHashes: ['hash1', 'hash2'],
queryTimings: array_fill(0, 10, 10.0),
queryComplexityScores: array_fill(0, 10, 0.5),
totalJoinCount: 5,
executedInLoop: true,
loopDepth: 2
);
$features = $extractor->extractFeatures($context);
$loopFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'loop_execution_detected'
))[0];
expect($loopFeature->value)->toBe(1.0);
expect($loopFeature->unit)->toBe('binary');
expect($loopFeature->metadata['loop_depth'])->toBe(2);
});
it('calculates high query similarity for N+1 pattern', function () {
$extractor = new QueryFeatureExtractor();
// 20 total queries, only 2 unique = 90% similarity
$context = QueryExecutionContext::minimal(
queryCount: 20,
durationMs: 200.0,
uniqueQueries: 2
);
$features = $extractor->extractFeatures($context);
$similarityFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'query_similarity_score'
))[0];
expect($similarityFeature->value)->toBe(0.9); // 1.0 - (2/20)
expect($similarityFeature->unit)->toBe('score');
});
it('calculates timing pattern regularity from consistent timings', function () {
$extractor = new QueryFeatureExtractor();
// Very consistent timings (low CV = high regularity)
$context = new QueryExecutionContext(
queryCount: 10,
duration: Duration::fromMilliseconds(100),
uniqueQueryHashes: ['hash1'],
queryTimings: [10.0, 10.1, 9.9, 10.0, 10.2, 9.8, 10.0, 10.1, 9.9, 10.0],
queryComplexityScores: array_fill(0, 10, 0.5),
totalJoinCount: 0,
executedInLoop: true
);
$features = $extractor->extractFeatures($context);
$regularityFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'timing_pattern_regularity'
))[0];
// High regularity expected (>0.9)
expect($regularityFeature->value)->toBeGreaterThan(0.9);
expect($regularityFeature->unit)->toBe('score');
});
it('calculates low timing pattern regularity from irregular timings', function () {
$extractor = new QueryFeatureExtractor();
// Very irregular timings (high CV = low regularity)
$context = new QueryExecutionContext(
queryCount: 10,
duration: Duration::fromMilliseconds(500),
uniqueQueryHashes: ['hash1', 'hash2', 'hash3'],
queryTimings: [5.0, 50.0, 10.0, 100.0, 8.0, 75.0, 12.0, 90.0, 6.0, 80.0],
queryComplexityScores: array_fill(0, 10, 0.5),
totalJoinCount: 0,
executedInLoop: false
);
$features = $extractor->extractFeatures($context);
$regularityFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'timing_pattern_regularity'
))[0];
// Low regularity expected (<0.5)
expect($regularityFeature->value)->toBeLessThan(0.5);
});
it('calculates average query complexity', function () {
$extractor = new QueryFeatureExtractor();
$context = new QueryExecutionContext(
queryCount: 5,
duration: Duration::fromMilliseconds(50),
uniqueQueryHashes: ['hash1', 'hash2'],
queryTimings: array_fill(0, 5, 10.0),
queryComplexityScores: [0.2, 0.4, 0.6, 0.8, 1.0],
totalJoinCount: 10,
executedInLoop: false
);
$features = $extractor->extractFeatures($context);
$complexityFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'avg_query_complexity'
))[0];
// Average of [0.2, 0.4, 0.6, 0.8, 1.0] = 0.6
expect($complexityFeature->value)->toBe(0.6);
expect($complexityFeature->unit)->toBe('score');
});
it('calculates average join count', function () {
$extractor = new QueryFeatureExtractor();
$context = new QueryExecutionContext(
queryCount: 10,
duration: Duration::fromMilliseconds(100),
uniqueQueryHashes: ['hash1'],
queryTimings: array_fill(0, 10, 10.0),
queryComplexityScores: array_fill(0, 10, 0.5),
totalJoinCount: 30, // 30 joins across 10 queries = 3.0 average
executedInLoop: false
);
$features = $extractor->extractFeatures($context);
$joinFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'avg_join_count'
))[0];
expect($joinFeature->value)->toBe(3.0);
expect($joinFeature->unit)->toBe('count');
});
it('handles zero queries gracefully', function () {
$extractor = new QueryFeatureExtractor();
$context = new QueryExecutionContext(
queryCount: 0,
duration: Duration::fromMilliseconds(0),
uniqueQueryHashes: [],
queryTimings: [],
queryComplexityScores: [],
totalJoinCount: 0,
executedInLoop: false
);
$features = $extractor->extractFeatures($context);
// Should return features with 0.0 values, not throw
expect($features)->not->toBeEmpty();
foreach ($features as $feature) {
expect($feature->value)->toBeFloat();
}
});
it('returns empty array for non-extractable data', function () {
$extractor = new QueryFeatureExtractor();
$features = $extractor->extractFeatures(['not' => 'context']);
expect($features)->toBeEmpty();
});
it('includes metadata in features', function () {
$extractor = new QueryFeatureExtractor();
$context = QueryExecutionContext::minimal(
queryCount: 5,
durationMs: 50.0,
uniqueQueries: 2
);
$features = $extractor->extractFeatures($context);
$frequencyFeature = array_values(array_filter(
$features,
fn($f) => $f->name === 'query_frequency'
))[0];
expect($frequencyFeature->metadata)->toHaveKey('query_count');
expect($frequencyFeature->metadata)->toHaveKey('duration_seconds');
expect($frequencyFeature->metadata['query_count'])->toBe(5);
});
});

View File

@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Database\NPlusOneDetection\MachineLearning;
use App\Framework\Database\NPlusOneDetection\MachineLearning\NPlusOneDetectionEngine;
use App\Framework\Database\NPlusOneDetection\MachineLearning\Extractors\QueryFeatureExtractor;
use App\Framework\Database\NPlusOneDetection\QueryExecutionContext;
use App\Framework\Waf\MachineLearning\Detectors\StatisticalAnomalyDetector;
use App\Framework\MachineLearning\ValueObjects\Feature;
use App\Framework\MachineLearning\ValueObjects\FeatureType;
use App\Framework\MachineLearning\ValueObjects\AnomalyDetection;
use App\Framework\MachineLearning\ValueObjects\AnomalyType;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\DateTime\SystemClock;
use Mockery;
use Mockery\MockInterface;
// Helper function to create mock extractor
function createMockQueryExtractor(bool $enabled = true, array $features = []): MockInterface
{
$extractor = Mockery::mock(\App\Framework\MachineLearning\Core\FeatureExtractorInterface::class);
$extractor->shouldReceive('isEnabled')->andReturn($enabled);
$extractor->shouldReceive('getFeatureType')->andReturn(FeatureType::FREQUENCY);
$extractor->shouldReceive('getPriority')->andReturn(10);
$extractor->shouldReceive('canExtract')->andReturn(true);
$extractor->shouldReceive('extractFeatures')->andReturn($features);
return $extractor;
}
// Helper function to create mock detector
function createMockQueryDetector(bool $enabled = true, array $supportedTypes = [], array $anomalies = []): MockInterface
{
$supportedTypes = $supportedTypes ?: [FeatureType::FREQUENCY];
$detector = Mockery::mock(\App\Framework\MachineLearning\Core\AnomalyDetectorInterface::class);
$detector->shouldReceive('isEnabled')->andReturn($enabled);
$detector->shouldReceive('getName')->andReturn('MockDetector');
$detector->shouldReceive('getSupportedFeatureTypes')->andReturn($supportedTypes);
$detector->shouldReceive('canAnalyze')->andReturn(true);
$detector->shouldReceive('detectAnomalies')->andReturn($anomalies);
return $detector;
}
describe('NPlusOneDetectionEngine', function () {
beforeEach(function () {
$this->clock = new SystemClock();
});
afterEach(function () {
Mockery::close();
});
it('is disabled when constructed with enabled: false', function () {
$engine = new NPlusOneDetectionEngine(
enabled: false,
extractors: [],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
expect($engine->isEnabled())->toBeFalse();
});
it('is enabled when constructed with enabled: true', function () {
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
expect($engine->isEnabled())->toBeTrue();
});
it('returns configuration', function () {
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [new QueryFeatureExtractor()],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(3),
confidenceThreshold: Percentage::from(75.0)
);
$config = $engine->getConfiguration();
expect($config)->toBeArray();
expect($config['enabled'])->toBeTrue();
expect($config['extractor_count'])->toBe(1);
expect($config['detector_count'])->toBe(0);
expect($config['analysis_timeout_ms'])->toBe(3000);
expect($config['confidence_threshold'])->toBe(75.0);
});
it('returns disabled result when engine is disabled', function () {
$engine = new NPlusOneDetectionEngine(
enabled: false,
extractors: [],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
expect($result->enabled)->toBeFalse();
expect($result->features)->toBeEmpty();
expect($result->anomalies)->toBeEmpty();
expect($result->confidence->getValue())->toBe(0.0);
});
it('extracts features from query context', function () {
$feature = new Feature(
type: FeatureType::FREQUENCY,
name: 'query_frequency',
value: 10.0,
unit: 'queries/second'
);
$extractor = createMockQueryExtractor(true, [$feature]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
expect($result->features)->toHaveCount(1);
expect($result->features[0]->name)->toBe('query_frequency');
});
it('detects anomalies using configured detectors', function () {
$feature = new Feature(
type: FeatureType::FREQUENCY,
name: 'query_frequency',
value: 100.0, // High frequency
unit: 'queries/second'
);
$anomaly = AnomalyDetection::frequencySpike(
currentRate: 100.0,
baseline: 10.0,
threshold: 3.0
);
$extractor = createMockQueryExtractor(true, [$feature]);
$detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [$detector],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
expect($result->anomalies)->not->toBeEmpty();
expect($result->confidence->getValue())->toBeGreaterThan(0.0);
});
it('filters anomalies by confidence threshold', function () {
$feature = new Feature(
type: FeatureType::FREQUENCY,
name: 'query_frequency',
value: 50.0,
unit: 'queries/second'
);
// Low confidence anomaly (should be filtered out)
$lowConfidenceAnomaly = AnomalyDetection::create(
type: AnomalyType::FREQUENCY_SPIKE,
featureType: FeatureType::FREQUENCY,
anomalyScore: 0.5, // 50% confidence
description: 'Low confidence anomaly',
features: [$feature],
evidence: []
);
$extractor = createMockQueryExtractor(true, [$feature]);
$detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$lowConfidenceAnomaly]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [$detector],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(75.0) // High threshold
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
// Anomaly should be filtered out due to low confidence
expect($result->anomalies)->toBeEmpty();
});
it('skips disabled extractors', function () {
$enabledExtractor = createMockQueryExtractor(true, [
new Feature(FeatureType::FREQUENCY, 'feature1', 10.0, 'unit')
]);
$disabledExtractor = createMockQueryExtractor(false, [
new Feature(FeatureType::FREQUENCY, 'feature2', 20.0, 'unit')
]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$enabledExtractor, $disabledExtractor],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
// Should only have features from enabled extractor
expect($result->features)->toHaveCount(1);
expect($result->features[0]->name)->toBe('feature1');
});
it('skips disabled detectors', function () {
$feature = new Feature(
type: FeatureType::FREQUENCY,
name: 'query_frequency',
value: 100.0,
unit: 'queries/second'
);
$anomaly = AnomalyDetection::frequencySpike(100.0, 10.0, 3.0);
$extractor = createMockQueryExtractor(true, [$feature]);
$enabledDetector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly]);
$disabledDetector = createMockQueryDetector(false, [FeatureType::FREQUENCY], [$anomaly]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [$enabledDetector, $disabledDetector],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
// Should only have anomalies from enabled detector
expect($result->detectorResults)->toHaveCount(1);
});
it('includes extractor results in output', function () {
$extractor = createMockQueryExtractor(true, [
new Feature(FeatureType::FREQUENCY, 'feature1', 10.0, 'unit')
]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
expect($result->extractorResults)->not->toBeEmpty();
expect($result->extractorResults[0]->featuresExtracted)->toBe(1);
expect($result->extractorResults[0]->success)->toBeTrue();
});
it('includes detector results in output', function () {
$feature = new Feature(
type: FeatureType::FREQUENCY,
name: 'query_frequency',
value: 100.0,
unit: 'queries/second'
);
$anomaly = AnomalyDetection::frequencySpike(100.0, 10.0, 3.0);
$extractor = createMockQueryExtractor(true, [$feature]);
$detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [$detector],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
expect($result->detectorResults)->not->toBeEmpty();
expect($result->detectorResults[0]->anomaliesDetected)->toBe(1);
expect($result->detectorResults[0]->success)->toBeTrue();
});
it('calculates overall confidence from anomalies', function () {
$feature = new Feature(
type: FeatureType::FREQUENCY,
name: 'query_frequency',
value: 100.0,
unit: 'queries/second'
);
$anomaly1 = AnomalyDetection::frequencySpike(100.0, 10.0, 3.0); // ~95% confidence
$anomaly2 = AnomalyDetection::frequencySpike(90.0, 10.0, 3.0); // ~95% confidence
$extractor = createMockQueryExtractor(true, [$feature]);
$detector = createMockQueryDetector(true, [FeatureType::FREQUENCY], [$anomaly1, $anomaly2]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [$detector],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
// Overall confidence should be average of anomalies
expect($result->confidence->getValue())->toBeGreaterThan(90.0);
});
it('handles exceptions gracefully', function () {
$extractor = Mockery::mock(\App\Framework\MachineLearning\Core\FeatureExtractorInterface::class);
$extractor->shouldReceive('isEnabled')->andReturn(true);
$extractor->shouldReceive('canExtract')->andReturn(true);
$extractor->shouldReceive('extractFeatures')->andThrow(new \RuntimeException('Extraction failed'));
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
// Should not throw, should return error in result
expect($result->error)->not->toBeNull();
expect($result->error)->toContain('Extraction failed');
});
it('tracks analysis time', function () {
$extractor = createMockQueryExtractor(true, [
new Feature(FeatureType::FREQUENCY, 'feature1', 10.0, 'unit')
]);
$engine = new NPlusOneDetectionEngine(
enabled: true,
extractors: [$extractor],
detectors: [],
clock: $this->clock,
analysisTimeout: Duration::fromSeconds(5),
confidenceThreshold: Percentage::from(60.0)
);
$context = QueryExecutionContext::minimal();
$result = $engine->analyzeQueryContext($context);
// Analysis time should be measured
expect($result->analysisTime->toMilliseconds())->toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Database\NPlusOneDetection;
use App\Framework\Database\NPlusOneDetection\QueryExecutionContext;
use App\Framework\Core\ValueObjects\Duration;
describe('QueryExecutionContext', function () {
it('can be constructed with all parameters', function () {
$context = new QueryExecutionContext(
queryCount: 10,
duration: Duration::fromMilliseconds(100),
uniqueQueryHashes: ['hash1', 'hash2'],
queryTimings: [10.0, 20.0, 30.0],
queryComplexityScores: [0.5, 0.6, 0.7],
totalJoinCount: 5,
executedInLoop: true,
loopDepth: 2,
metadata: ['key' => 'value']
);
expect($context->queryCount)->toBe(10);
expect($context->duration->toMilliseconds())->toBe(100);
expect($context->uniqueQueryHashes)->toHaveCount(2);
expect($context->queryTimings)->toHaveCount(3);
expect($context->queryComplexityScores)->toHaveCount(3);
expect($context->totalJoinCount)->toBe(5);
expect($context->executedInLoop)->toBeTrue();
expect($context->loopDepth)->toBe(2);
expect($context->metadata)->toHaveKey('key');
});
it('creates minimal context', function () {
$context = QueryExecutionContext::minimal();
expect($context->queryCount)->toBe(1);
expect($context->duration->toMilliseconds())->toBe(10);
expect($context->uniqueQueryHashes)->toHaveCount(1);
expect($context->executedInLoop)->toBeFalse();
expect($context->loopDepth)->toBeNull();
});
it('creates minimal context with custom parameters', function () {
$context = QueryExecutionContext::minimal(
queryCount: 5,
durationMs: 50.0,
uniqueQueries: 2
);
expect($context->queryCount)->toBe(5);
expect($context->duration->toMilliseconds())->toBe(50);
expect($context->uniqueQueryHashes)->toHaveCount(2);
});
it('creates context from query array', function () {
$queries = [
['query' => 'SELECT * FROM users WHERE id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM users WHERE id = 2', 'duration' => 12.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM posts WHERE user_id = 1', 'duration' => 15.0, 'complexity' => 0.6, 'joins' => 1],
];
$context = QueryExecutionContext::fromQueries($queries);
expect($context->queryCount)->toBe(3);
expect($context->duration->toMilliseconds())->toBe(37); // 10 + 12 + 15
expect($context->queryTimings)->toBe([10.0, 12.0, 15.0]);
expect($context->queryComplexityScores)->toBe([0.5, 0.5, 0.6]);
expect($context->totalJoinCount)->toBe(1);
});
it('normalizes and deduplicates similar queries', function () {
$queries = [
['query' => 'SELECT * FROM users WHERE id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM users WHERE id = 2', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM users WHERE id = 3', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM posts WHERE user_id = 1', 'duration' => 15.0, 'complexity' => 0.6, 'joins' => 1],
];
$context = QueryExecutionContext::fromQueries($queries);
// 4 total queries, but only 2 unique (users query normalized, posts query different)
expect($context->queryCount)->toBe(4);
expect($context->uniqueQueryHashes)->toHaveCount(2);
});
it('detects N+1 pattern when high repetition + loop execution', function () {
$queries = [];
for ($i = 0; $i < 10; $i++) {
$queries[] = [
'query' => "SELECT * FROM users WHERE id = {$i}",
'duration' => 10.0,
'complexity' => 0.5,
'joins' => 0
];
}
$context = QueryExecutionContext::fromQueries($queries, executedInLoop: true);
// 10 queries, 1 unique (all normalized to same hash) = 90% repetition
expect($context->hasNPlusOnePattern())->toBeTrue();
});
it('does not detect N+1 pattern when not executed in loop', function () {
$queries = [];
for ($i = 0; $i < 10; $i++) {
$queries[] = [
'query' => "SELECT * FROM users WHERE id = {$i}",
'duration' => 10.0,
'complexity' => 0.5,
'joins' => 0
];
}
$context = QueryExecutionContext::fromQueries($queries, executedInLoop: false);
// High repetition but no loop = no N+1 pattern
expect($context->hasNPlusOnePattern())->toBeFalse();
});
it('does not detect N+1 pattern with low repetition', function () {
$queries = [
['query' => 'SELECT * FROM users', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM posts', 'duration' => 15.0, 'complexity' => 0.6, 'joins' => 1],
['query' => 'SELECT * FROM comments', 'duration' => 12.0, 'complexity' => 0.7, 'joins' => 2],
];
$context = QueryExecutionContext::fromQueries($queries, executedInLoop: true);
// 3 queries, 3 unique = 0% repetition
expect($context->hasNPlusOnePattern())->toBeFalse();
});
it('does not detect N+1 pattern with less than 3 queries', function () {
$queries = [
['query' => 'SELECT * FROM users WHERE id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM users WHERE id = 2', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
];
$context = QueryExecutionContext::fromQueries($queries, executedInLoop: true);
// Less than 3 queries = no N+1 pattern
expect($context->hasNPlusOnePattern())->toBeFalse();
});
it('calculates repetition rate correctly', function () {
$queries = [];
for ($i = 0; $i < 10; $i++) {
$queries[] = [
'query' => "SELECT * FROM users WHERE id = {$i}",
'duration' => 10.0,
'complexity' => 0.5,
'joins' => 0
];
}
$context = QueryExecutionContext::fromQueries($queries);
// 10 total, 1 unique = 90% repetition
$repetitionRate = $context->getRepetitionRate();
expect($repetitionRate)->toBe(90.0);
});
it('calculates zero repetition rate for unique queries', function () {
$queries = [
['query' => 'SELECT * FROM users', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM posts', 'duration' => 15.0, 'complexity' => 0.6, 'joins' => 1],
['query' => 'SELECT * FROM comments', 'duration' => 12.0, 'complexity' => 0.7, 'joins' => 2],
];
$context = QueryExecutionContext::fromQueries($queries);
// 3 total, 3 unique = 0% repetition
$repetitionRate = $context->getRepetitionRate();
expect($repetitionRate)->toBe(0.0);
});
it('calculates average execution time', function () {
$context = new QueryExecutionContext(
queryCount: 5,
duration: Duration::fromMilliseconds(100),
uniqueQueryHashes: ['hash1'],
queryTimings: [20.0, 20.0, 20.0, 20.0, 20.0],
queryComplexityScores: [0.5, 0.5, 0.5, 0.5, 0.5],
totalJoinCount: 0,
executedInLoop: false
);
// 100ms total / 5 queries = 20ms average
$avgTime = $context->getAverageExecutionTime();
expect($avgTime)->toBe(20.0);
});
it('handles zero queries gracefully', function () {
$context = new QueryExecutionContext(
queryCount: 0,
duration: Duration::fromMilliseconds(0),
uniqueQueryHashes: [],
queryTimings: [],
queryComplexityScores: [],
totalJoinCount: 0,
executedInLoop: false
);
expect($context->hasNPlusOnePattern())->toBeFalse();
expect($context->getRepetitionRate())->toBe(0.0);
expect($context->getAverageExecutionTime())->toBe(0.0);
});
it('normalizes query with different parameter values to same hash', function () {
$queries = [
['query' => 'SELECT * FROM users WHERE id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM users WHERE id = 999', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => "SELECT * FROM users WHERE id = '5'", 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
];
$context = QueryExecutionContext::fromQueries($queries);
// All should normalize to same query pattern (different parameter values)
expect($context->queryCount)->toBe(3);
expect($context->uniqueQueryHashes)->toHaveCount(1);
});
it('normalizes query with different whitespace to same hash', function () {
$queries = [
['query' => 'SELECT * FROM users WHERE id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SELECT * FROM users WHERE id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => "SELECT\n*\nFROM\nusers\nWHERE\nid\n=\n1", 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
];
$context = QueryExecutionContext::fromQueries($queries);
// All should normalize to same query (different whitespace)
expect($context->queryCount)->toBe(3);
expect($context->uniqueQueryHashes)->toHaveCount(1);
});
it('normalizes query case-insensitively', function () {
$queries = [
['query' => 'SELECT * FROM users WHERE id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'select * from users where id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
['query' => 'SeLeCt * FrOm UsErS wHeRe Id = 1', 'duration' => 10.0, 'complexity' => 0.5, 'joins' => 0],
];
$context = QueryExecutionContext::fromQueries($queries);
// All should normalize to same query (different case)
expect($context->queryCount)->toBe(3);
expect($context->uniqueQueryHashes)->toHaveCount(1);
});
it('tracks loop depth when provided', function () {
$context = QueryExecutionContext::fromQueries(
[],
executedInLoop: true,
loopDepth: 3
);
expect($context->executedInLoop)->toBeTrue();
expect($context->loopDepth)->toBe(3);
});
});