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:
168
tests/Framework/Database/ChangeTrackingLogicTest.php
Normal file
168
tests/Framework/Database/ChangeTrackingLogicTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
// Simple test to verify change tracking logic
|
||||
test('change tracking detects property changes correctly', function () {
|
||||
// Test class
|
||||
$testClass = new class () {
|
||||
public function __construct(
|
||||
public readonly int $id = 1,
|
||||
public string $name = '',
|
||||
public string $email = '',
|
||||
public int $age = 0,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
$original = new $testClass(id: 1, name: 'John Doe', email: 'john@example.com', age: 30);
|
||||
$modified = new $testClass(id: 1, name: 'John Smith', email: 'john@example.com', age: 31);
|
||||
|
||||
// Simulate change detection
|
||||
$changes = [];
|
||||
$oldValues = [];
|
||||
$newValues = [];
|
||||
|
||||
$reflectionClass = new ReflectionClass($original);
|
||||
foreach ($reflectionClass->getProperties() as $property) {
|
||||
if ($property->getName() === 'id') {
|
||||
continue;
|
||||
} // Skip ID
|
||||
|
||||
$property->setAccessible(true);
|
||||
$oldValue = $property->getValue($original);
|
||||
$newValue = $property->getValue($modified);
|
||||
|
||||
if ($oldValue !== $newValue) {
|
||||
$changes[] = $property->getName();
|
||||
$oldValues[$property->getName()] = $oldValue;
|
||||
$newValues[$property->getName()] = $newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Assertions
|
||||
expect($changes)->toBe(['name', 'age']);
|
||||
expect($oldValues)->toBe(['name' => 'John Doe', 'age' => 30]);
|
||||
expect($newValues)->toBe(['name' => 'John Smith', 'age' => 31]);
|
||||
});
|
||||
|
||||
test('change tracking detects no changes when objects are identical', function () {
|
||||
$testClass = new class () {
|
||||
public function __construct(
|
||||
public readonly int $id = 1,
|
||||
public string $name = '',
|
||||
public string $email = '',
|
||||
public int $age = 0,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
$original = new $testClass(id: 1, name: 'John Doe', email: 'john@example.com', age: 30);
|
||||
$identical = new $testClass(id: 1, name: 'John Doe', email: 'john@example.com', age: 30);
|
||||
|
||||
// Simulate change detection
|
||||
$changes = [];
|
||||
$reflectionClass = new ReflectionClass($original);
|
||||
foreach ($reflectionClass->getProperties() as $property) {
|
||||
if ($property->getName() === 'id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$property->setAccessible(true);
|
||||
$oldValue = $property->getValue($original);
|
||||
$newValue = $property->getValue($identical);
|
||||
|
||||
if ($oldValue !== $newValue) {
|
||||
$changes[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
expect($changes)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('change tracking handles type-sensitive comparisons', function () {
|
||||
$testClass = new class () {
|
||||
public function __construct(
|
||||
public readonly int $id = 1,
|
||||
public mixed $value = null,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
$original = new $testClass(id: 1, value: 0);
|
||||
$modified = new $testClass(id: 1, value: '0'); // String vs int
|
||||
|
||||
// Simulate change detection
|
||||
$changes = [];
|
||||
$reflectionClass = new ReflectionClass($original);
|
||||
foreach ($reflectionClass->getProperties() as $property) {
|
||||
if ($property->getName() === 'id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$property->setAccessible(true);
|
||||
$oldValue = $property->getValue($original);
|
||||
$newValue = $property->getValue($modified);
|
||||
|
||||
if ($oldValue !== $newValue) {
|
||||
$changes[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
// Should detect change due to type difference (0 !== '0')
|
||||
expect($changes)->toBe(['value']);
|
||||
});
|
||||
|
||||
test('change tracking handles null values correctly', function () {
|
||||
$testClass = new class () {
|
||||
public function __construct(
|
||||
public readonly int $id = 1,
|
||||
public ?string $nullable = null,
|
||||
) {
|
||||
}
|
||||
};
|
||||
|
||||
// Test 1: null to value
|
||||
$original = new $testClass(id: 1, nullable: null);
|
||||
$modified = new $testClass(id: 1, nullable: 'value');
|
||||
|
||||
$changes = [];
|
||||
$reflectionClass = new ReflectionClass($original);
|
||||
foreach ($reflectionClass->getProperties() as $property) {
|
||||
if ($property->getName() === 'id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$property->setAccessible(true);
|
||||
$oldValue = $property->getValue($original);
|
||||
$newValue = $property->getValue($modified);
|
||||
|
||||
if ($oldValue !== $newValue) {
|
||||
$changes[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
expect($changes)->toBe(['nullable']);
|
||||
|
||||
// Test 2: value to null
|
||||
$original2 = new $testClass(id: 1, nullable: 'value');
|
||||
$modified2 = new $testClass(id: 1, nullable: null);
|
||||
|
||||
$changes2 = [];
|
||||
foreach ($reflectionClass->getProperties() as $property) {
|
||||
if ($property->getName() === 'id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$property->setAccessible(true);
|
||||
$oldValue = $property->getValue($original2);
|
||||
$newValue = $property->getValue($modified2);
|
||||
|
||||
if ($oldValue !== $newValue) {
|
||||
$changes2[] = $property->getName();
|
||||
}
|
||||
}
|
||||
|
||||
expect($changes2)->toBe(['nullable']);
|
||||
});
|
||||
231
tests/Framework/Database/EagerLoadingLogicTest.php
Normal file
231
tests/Framework/Database/EagerLoadingLogicTest.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Test logic for eager loading without mocking complex dependencies
|
||||
test('eager loading performance simulation shows query reduction', function () {
|
||||
// Simulate N+1 problem vs eager loading performance
|
||||
|
||||
// Scenario: 100 users, each with posts and profile
|
||||
$userCount = 100;
|
||||
$avgPostsPerUser = 5;
|
||||
$profilePerUser = 1;
|
||||
|
||||
// N+1 Problem Simulation
|
||||
$n1QueriesLazy = 1 + ($userCount * 2); // 1 query for users + 1 query per user for posts + 1 query per user for profile
|
||||
|
||||
// Eager Loading Simulation
|
||||
$eagerQueries = 1 + 2; // 1 query for users + 1 batch query for posts + 1 batch query for profiles
|
||||
|
||||
$queryReduction = ($n1QueriesLazy - $eagerQueries) / $n1QueriesLazy;
|
||||
|
||||
expect($eagerQueries)->toBeLessThan($n1QueriesLazy);
|
||||
expect($queryReduction)->toBeGreaterThan(0.95); // Over 95% query reduction
|
||||
|
||||
// Real numbers: 201 queries vs 3 queries = 98.5% reduction!
|
||||
});
|
||||
|
||||
test('batch loading logic groups related entities correctly', function () {
|
||||
// Simulate the batch loading grouping logic
|
||||
|
||||
// Users data
|
||||
$users = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
['id' => 3, 'name' => 'Charlie'],
|
||||
];
|
||||
|
||||
// Posts data (simulating database result)
|
||||
$allPosts = [
|
||||
['id' => 1, 'user_id' => 1, 'title' => 'Alice Post 1'],
|
||||
['id' => 2, 'user_id' => 1, 'title' => 'Alice Post 2'],
|
||||
['id' => 3, 'user_id' => 2, 'title' => 'Bob Post 1'],
|
||||
['id' => 4, 'user_id' => 3, 'title' => 'Charlie Post 1'],
|
||||
['id' => 5, 'user_id' => 3, 'title' => 'Charlie Post 2'],
|
||||
['id' => 6, 'user_id' => 3, 'title' => 'Charlie Post 3'],
|
||||
];
|
||||
|
||||
// Simulate batch loading grouping (what our implementation does)
|
||||
$postsByUserId = [];
|
||||
foreach ($allPosts as $post) {
|
||||
$userId = $post['user_id'];
|
||||
if (! isset($postsByUserId[$userId])) {
|
||||
$postsByUserId[$userId] = [];
|
||||
}
|
||||
$postsByUserId[$userId][] = $post;
|
||||
}
|
||||
|
||||
// Verify correct grouping
|
||||
expect($postsByUserId[1])->toHaveCount(2); // Alice has 2 posts
|
||||
expect($postsByUserId[2])->toHaveCount(1); // Bob has 1 post
|
||||
expect($postsByUserId[3])->toHaveCount(3); // Charlie has 3 posts
|
||||
|
||||
// Verify no posts are lost
|
||||
$totalPosts = array_sum(array_map('count', $postsByUserId));
|
||||
expect($totalPosts)->toBe(count($allPosts));
|
||||
|
||||
// Verify all users can get their posts
|
||||
foreach ($users as $user) {
|
||||
$userId = $user['id'];
|
||||
$userPosts = $postsByUserId[$userId] ?? [];
|
||||
|
||||
// Each post should belong to the correct user
|
||||
foreach ($userPosts as $post) {
|
||||
expect($post['user_id'])->toBe($userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('belongsTo relation batching reduces queries', function () {
|
||||
// Simulate belongsTo relation (posts belonging to categories)
|
||||
|
||||
$posts = [
|
||||
['id' => 1, 'title' => 'Post 1', 'category_id' => 1],
|
||||
['id' => 2, 'title' => 'Post 2', 'category_id' => 2],
|
||||
['id' => 3, 'title' => 'Post 3', 'category_id' => 1],
|
||||
['id' => 4, 'title' => 'Post 4', 'category_id' => 3],
|
||||
['id' => 5, 'title' => 'Post 5', 'category_id' => 2],
|
||||
];
|
||||
|
||||
// Without batching: 1 query per post for category = 5 queries
|
||||
$lazyQueries = count($posts);
|
||||
|
||||
// With batching: Collect all unique category_ids, then 1 IN query
|
||||
$categoryIds = array_unique(array_column($posts, 'category_id'));
|
||||
$batchQueries = 1; // Single IN query: SELECT * FROM categories WHERE id IN (1,2,3)
|
||||
|
||||
expect($batchQueries)->toBeLessThan($lazyQueries);
|
||||
expect(count($categoryIds))->toBeLessThan(count($posts)); // Proof that we're reducing queries
|
||||
|
||||
// Simulate batch loading result
|
||||
$categories = [
|
||||
['id' => 1, 'name' => 'Tech'],
|
||||
['id' => 2, 'name' => 'Science'],
|
||||
['id' => 3, 'name' => 'Art'],
|
||||
];
|
||||
|
||||
// Index by ID for fast lookup (what our implementation does)
|
||||
$categoriesById = [];
|
||||
foreach ($categories as $category) {
|
||||
$categoriesById[$category['id']] = $category;
|
||||
}
|
||||
|
||||
// Verify we can resolve all post categories
|
||||
foreach ($posts as $post) {
|
||||
$categoryId = $post['category_id'];
|
||||
expect($categoriesById)->toHaveKey($categoryId);
|
||||
expect($categoriesById[$categoryId]['id'])->toBe($categoryId);
|
||||
}
|
||||
});
|
||||
|
||||
test('one-to-one relation batching works correctly', function () {
|
||||
// Simulate one-to-one relation (users with profiles)
|
||||
|
||||
$users = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
['id' => 3, 'name' => 'Charlie'],
|
||||
];
|
||||
|
||||
$profiles = [
|
||||
['id' => 1, 'user_id' => 1, 'bio' => 'Alice bio'],
|
||||
['id' => 2, 'user_id' => 2, 'bio' => 'Bob bio'],
|
||||
['id' => 3, 'user_id' => 3, 'bio' => 'Charlie bio'],
|
||||
];
|
||||
|
||||
// Batch loading: Single query with IN clause
|
||||
$userIds = array_column($users, 'id');
|
||||
$batchQuery = "SELECT * FROM profiles WHERE user_id IN (" . implode(',', $userIds) . ")";
|
||||
|
||||
expect($batchQuery)->toContain('IN (1,2,3)');
|
||||
|
||||
// Group profiles by user_id (one-to-one, so each user has max 1 profile)
|
||||
$profileByUserId = [];
|
||||
foreach ($profiles as $profile) {
|
||||
$profileByUserId[$profile['user_id']] = $profile;
|
||||
}
|
||||
|
||||
// Verify one-to-one relationship
|
||||
expect($profileByUserId)->toHaveCount(3);
|
||||
expect($profileByUserId[1]['bio'])->toBe('Alice bio');
|
||||
expect($profileByUserId[2]['bio'])->toBe('Bob bio');
|
||||
expect($profileByUserId[3]['bio'])->toBe('Charlie bio');
|
||||
});
|
||||
|
||||
test('eager loading handles missing relations gracefully', function () {
|
||||
// Test scenario where some entities don't have relations
|
||||
|
||||
$users = [
|
||||
['id' => 1, 'name' => 'Alice'],
|
||||
['id' => 2, 'name' => 'Bob'],
|
||||
['id' => 3, 'name' => 'Charlie'],
|
||||
];
|
||||
|
||||
// Only some users have posts
|
||||
$posts = [
|
||||
['id' => 1, 'user_id' => 1, 'title' => 'Alice Post'],
|
||||
['id' => 2, 'user_id' => 3, 'title' => 'Charlie Post'],
|
||||
// Bob (user_id: 2) has no posts
|
||||
];
|
||||
|
||||
// Group posts by user_id
|
||||
$postsByUserId = [];
|
||||
foreach ($posts as $post) {
|
||||
$userId = $post['user_id'];
|
||||
if (! isset($postsByUserId[$userId])) {
|
||||
$postsByUserId[$userId] = [];
|
||||
}
|
||||
$postsByUserId[$userId][] = $post;
|
||||
}
|
||||
|
||||
// Assign relations to users
|
||||
$usersWithPosts = [];
|
||||
foreach ($users as $user) {
|
||||
$userId = $user['id'];
|
||||
$usersWithPosts[] = [
|
||||
'user' => $user,
|
||||
'posts' => $postsByUserId[$userId] ?? [], // Default to empty array if no posts
|
||||
];
|
||||
}
|
||||
|
||||
// Verify handling of missing relations
|
||||
expect($usersWithPosts[0]['posts'])->toHaveCount(1); // Alice has 1 post
|
||||
expect($usersWithPosts[1]['posts'])->toHaveCount(0); // Bob has 0 posts
|
||||
expect($usersWithPosts[2]['posts'])->toHaveCount(1); // Charlie has 1 post
|
||||
|
||||
// Verify no user is missing from result
|
||||
expect($usersWithPosts)->toHaveCount(3);
|
||||
});
|
||||
|
||||
test('complex eager loading scenario performance calculation', function () {
|
||||
// Real-world scenario: Blog system with nested relations
|
||||
|
||||
$postCount = 50;
|
||||
$avgCommentsPerPost = 8;
|
||||
$avgTagsPerPost = 3;
|
||||
$avgCategoriesPerPost = 1; // belongsTo relation
|
||||
|
||||
// Without eager loading (N+1 problem)
|
||||
$lazyQueries = 1; // Posts query
|
||||
$lazyQueries += $postCount; // 1 query per post for category (belongsTo)
|
||||
$lazyQueries += $postCount; // 1 query per post for comments (hasMany)
|
||||
$lazyQueries += $postCount; // 1 query per post for tags (hasMany)
|
||||
// Total: 1 + 50 + 50 + 50 = 151 queries
|
||||
|
||||
// With eager loading
|
||||
$eagerQueries = 1; // Posts query
|
||||
$eagerQueries += 1; // Batch query for all categories (IN clause)
|
||||
$eagerQueries += 1; // Batch query for all comments (WHERE post_id IN ...)
|
||||
$eagerQueries += 1; // Batch query for all tags (JOIN with pivot table)
|
||||
// Total: 4 queries
|
||||
|
||||
$performanceGain = ($lazyQueries - $eagerQueries) / $lazyQueries;
|
||||
|
||||
expect($eagerQueries)->toBe(4);
|
||||
expect($lazyQueries)->toBe(151);
|
||||
expect($performanceGain)->toBeGreaterThan(0.97); // Over 97% query reduction!
|
||||
|
||||
// Database load reduction
|
||||
$loadReduction = $lazyQueries / $eagerQueries;
|
||||
expect($loadReduction)->toBeGreaterThan(35); // 37.75x fewer queries
|
||||
});
|
||||
305
tests/Framework/Database/MasterSlaveRouterTest.php
Normal file
305
tests/Framework/Database/MasterSlaveRouterTest.php
Normal file
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
test('weighted selection respects configuration weights', function () {
|
||||
// Test the weighted selection algorithm
|
||||
|
||||
// Scenario: 3 replicas with different weights
|
||||
$replicas = [
|
||||
['weight' => 100, 'name' => 'replica_1'],
|
||||
['weight' => 200, 'name' => 'replica_2'], // 2x weight
|
||||
['weight' => 50, 'name' => 'replica_3'], // 0.5x weight
|
||||
];
|
||||
|
||||
$totalWeight = array_sum(array_column($replicas, 'weight')); // 350
|
||||
|
||||
// Simulate weighted selection distribution
|
||||
$selections = [];
|
||||
$iterations = 1000;
|
||||
|
||||
for ($i = 0; $i < $iterations; $i++) {
|
||||
$randomValue = mt_rand(0, $totalWeight - 1);
|
||||
$currentWeight = 0;
|
||||
|
||||
foreach ($replicas as $index => $replica) {
|
||||
$currentWeight += $replica['weight'];
|
||||
if ($randomValue < $currentWeight) {
|
||||
$selections[$index] = ($selections[$index] ?? 0) + 1;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
$percentages = [];
|
||||
foreach ($selections as $index => $count) {
|
||||
$percentages[$index] = ($count / $iterations) * 100;
|
||||
}
|
||||
|
||||
// Expected distributions (with some tolerance for randomness)
|
||||
$expectedPercentages = [
|
||||
0 => (100 / 350) * 100, // ~28.57%
|
||||
1 => (200 / 350) * 100, // ~57.14%
|
||||
2 => (50 / 350) * 100, // ~14.29%
|
||||
];
|
||||
|
||||
// Verify distribution is roughly correct (±5% tolerance)
|
||||
foreach ($expectedPercentages as $index => $expected) {
|
||||
$actual = $percentages[$index] ?? 0;
|
||||
expect($actual)->toBeGreaterThan($expected - 5)
|
||||
->and($actual)->toBeLessThan($expected + 5);
|
||||
}
|
||||
|
||||
// Verify replica_2 gets roughly twice as many selections as replica_1
|
||||
$ratio = ($percentages[1] ?? 0) / ($percentages[0] ?? 1);
|
||||
expect($ratio)->toBeGreaterThan(1.5)
|
||||
->and($ratio)->toBeLessThan(2.5); // 2.0 ± 0.5
|
||||
});
|
||||
|
||||
test('weight adjustment based on load factor works correctly', function () {
|
||||
// Test load-based weight adjustment
|
||||
|
||||
$baseWeight = 100;
|
||||
$maxConnections = 10;
|
||||
|
||||
// Test cases: [current_connections, expected_load_factor]
|
||||
$testCases = [
|
||||
[0, 1.0], // No load = full weight
|
||||
[5, 0.5], // 50% load = 50% weight
|
||||
[8, 0.2], // 80% load = 20% weight
|
||||
[10, 0.1], // 100% load = minimum 10% weight
|
||||
[15, 0.1], // Over capacity = minimum 10% weight
|
||||
];
|
||||
|
||||
foreach ($testCases as [$currentConnections, $expectedLoadFactor]) {
|
||||
$loadFactor = $maxConnections > 0 ? 1 - ($currentConnections / $maxConnections) : 1;
|
||||
$loadFactor = max(0.1, $loadFactor); // Minimum 10% weight
|
||||
|
||||
expect($loadFactor)->toBeGreaterThanOrEqual($expectedLoadFactor - 0.001)
|
||||
->and($loadFactor)->toBeLessThanOrEqual($expectedLoadFactor + 0.001);
|
||||
|
||||
$adjustedWeight = (int)round($baseWeight * $loadFactor);
|
||||
$expectedWeight = (int)round($baseWeight * $expectedLoadFactor);
|
||||
|
||||
expect($adjustedWeight)->toBe($expectedWeight);
|
||||
}
|
||||
});
|
||||
|
||||
test('response time factor adjusts weight correctly', function () {
|
||||
// Test response time-based weight adjustment
|
||||
|
||||
$baseWeight = 100;
|
||||
|
||||
// Test cases: [avg_response_time_ms, expected_min_factor]
|
||||
$testCases = [
|
||||
[50.0, 1.0], // Very fast = full weight (100/50 = 2.0, capped at 1.0)
|
||||
[100.0, 1.0], // Fast = full weight (100/100 = 1.0)
|
||||
[200.0, 0.5], // Slow = 50% weight (100/200 = 0.5)
|
||||
[500.0, 0.2], // Very slow = 20% weight (100/500 = 0.2)
|
||||
[1000.0, 0.1], // Extremely slow = minimum 10% weight
|
||||
];
|
||||
|
||||
foreach ($testCases as [$responseTime, $expectedMinFactor]) {
|
||||
$responseFactor = $responseTime > 0 ? min(1.0, 100 / $responseTime) : 1;
|
||||
$responseFactor = max(0.1, $responseFactor); // Minimum 10% weight
|
||||
|
||||
expect($responseFactor)->toBeGreaterThanOrEqual($expectedMinFactor - 0.01)
|
||||
->and($responseFactor)->toBeLessThanOrEqual($expectedMinFactor + 0.01);
|
||||
}
|
||||
});
|
||||
|
||||
test('connection counting tracks connections correctly', function () {
|
||||
// Test connection count tracking logic
|
||||
|
||||
$connectionCounts = [0, 0, 0]; // 3 replicas, all start at 0
|
||||
|
||||
// Simulate connection increments
|
||||
$operations = [
|
||||
['replica' => 0, 'operation' => 'increment'],
|
||||
['replica' => 1, 'operation' => 'increment'],
|
||||
['replica' => 0, 'operation' => 'increment'],
|
||||
['replica' => 2, 'operation' => 'increment'],
|
||||
['replica' => 1, 'operation' => 'increment'],
|
||||
['replica' => 0, 'operation' => 'decrement'],
|
||||
['replica' => 1, 'operation' => 'increment'],
|
||||
];
|
||||
|
||||
foreach ($operations as $op) {
|
||||
$replicaIndex = $op['replica'];
|
||||
if ($op['operation'] === 'increment') {
|
||||
$connectionCounts[$replicaIndex]++;
|
||||
} else {
|
||||
$connectionCounts[$replicaIndex] = max(0, $connectionCounts[$replicaIndex] - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Expected final counts
|
||||
expect($connectionCounts[0])->toBe(1); // 2 increments, 1 decrement = 1
|
||||
expect($connectionCounts[1])->toBe(3); // 3 increments = 3
|
||||
expect($connectionCounts[2])->toBe(1); // 1 increment = 1
|
||||
|
||||
// Test decrement doesn't go below 0
|
||||
$connectionCounts[2]--;
|
||||
$connectionCounts[2] = max(0, $connectionCounts[2]);
|
||||
expect($connectionCounts[2])->toBe(0);
|
||||
|
||||
$connectionCounts[2]--; // Should stay at 0
|
||||
$connectionCounts[2] = max(0, $connectionCounts[2]);
|
||||
expect($connectionCounts[2])->toBe(0);
|
||||
});
|
||||
|
||||
test('response time history maintains correct window size', function () {
|
||||
// Test response time history window management
|
||||
|
||||
$maxHistorySize = 5; // Smaller window for testing
|
||||
$responseHistory = [];
|
||||
|
||||
// Add response times
|
||||
$responseTimes = [100, 150, 200, 120, 180, 90, 110, 160];
|
||||
|
||||
foreach ($responseTimes as $time) {
|
||||
$responseHistory[] = $time;
|
||||
|
||||
// Keep only last N response times
|
||||
if (count($responseHistory) > $maxHistorySize) {
|
||||
array_shift($responseHistory);
|
||||
}
|
||||
}
|
||||
|
||||
// Should contain only the last 5 values
|
||||
expect($responseHistory)->toHaveCount($maxHistorySize);
|
||||
expect($responseHistory)->toBe([120, 180, 90, 110, 160]); // Last 5 values
|
||||
|
||||
// Let's recalculate correctly
|
||||
$responseHistory = [];
|
||||
foreach ($responseTimes as $time) {
|
||||
$responseHistory[] = $time;
|
||||
if (count($responseHistory) > $maxHistorySize) {
|
||||
array_shift($responseHistory);
|
||||
}
|
||||
}
|
||||
|
||||
expect($responseHistory)->toHaveCount($maxHistorySize);
|
||||
expect($responseHistory)->toBe([120, 180, 90, 110, 160]); // Last 5 values
|
||||
|
||||
// Test average calculation
|
||||
$average = array_sum($responseHistory) / count($responseHistory);
|
||||
$expectedAverage = (120 + 180 + 90 + 110 + 160) / 5; // 132
|
||||
|
||||
expect($average)->toBe($expectedAverage);
|
||||
});
|
||||
|
||||
test('load balancing strategy selection logic', function () {
|
||||
// Test different load balancing strategies
|
||||
|
||||
$strategies = [
|
||||
'ROUND_ROBIN' => 'Simple round-robin selection',
|
||||
'WEIGHTED' => 'Weight-based selection with load adjustment',
|
||||
'LEAST_CONNECTIONS' => 'Select replica with fewest connections',
|
||||
'RESPONSE_TIME' => 'Select replica with best response time',
|
||||
];
|
||||
|
||||
// Simulate replica data
|
||||
$replicas = [
|
||||
['connections' => 5, 'weight' => 100, 'response_time' => 120],
|
||||
['connections' => 8, 'weight' => 150, 'response_time' => 90],
|
||||
['connections' => 3, 'weight' => 80, 'response_time' => 200],
|
||||
];
|
||||
|
||||
// Test LEAST_CONNECTIONS logic
|
||||
$minConnections = min(array_column($replicas, 'connections'));
|
||||
$leastConnectionsIndex = array_search($minConnections, array_column($replicas, 'connections'));
|
||||
expect($leastConnectionsIndex)->toBe(2); // Index 2 has 3 connections (minimum)
|
||||
|
||||
// Test RESPONSE_TIME logic
|
||||
$minResponseTime = min(array_column($replicas, 'response_time'));
|
||||
$fastestReplicaIndex = array_search($minResponseTime, array_column($replicas, 'response_time'));
|
||||
expect($fastestReplicaIndex)->toBe(1); // Index 1 has 90ms response time (minimum)
|
||||
|
||||
// Test weight calculation with all factors
|
||||
foreach ($replicas as $index => $replica) {
|
||||
$maxConnections = 10;
|
||||
$loadFactor = max(0.1, 1 - ($replica['connections'] / $maxConnections));
|
||||
$responseFactor = max(0.1, min(1.0, 100 / $replica['response_time']));
|
||||
$adjustedWeight = (int)($replica['weight'] * $loadFactor * $responseFactor);
|
||||
|
||||
// Verify minimum weight is maintained
|
||||
expect($adjustedWeight)->toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('routing statistics provide comprehensive monitoring data', function () {
|
||||
// Test statistics generation logic
|
||||
|
||||
$replicas = [
|
||||
[
|
||||
'healthy' => true,
|
||||
'connections' => 5,
|
||||
'max_connections' => 10,
|
||||
'weight' => 100,
|
||||
'total_queries' => 1000,
|
||||
'failed_queries' => 10,
|
||||
'response_samples' => 50,
|
||||
],
|
||||
[
|
||||
'healthy' => false,
|
||||
'connections' => 0,
|
||||
'max_connections' => 10,
|
||||
'weight' => 150,
|
||||
'total_queries' => 0,
|
||||
'failed_queries' => 0,
|
||||
'response_samples' => 0,
|
||||
],
|
||||
[
|
||||
'healthy' => true,
|
||||
'connections' => 8,
|
||||
'max_connections' => 10,
|
||||
'weight' => 80,
|
||||
'total_queries' => 500,
|
||||
'failed_queries' => 5,
|
||||
'response_samples' => 25,
|
||||
],
|
||||
];
|
||||
|
||||
// Calculate expected statistics
|
||||
$totalReplicas = count($replicas);
|
||||
$healthyReplicas = count(array_filter($replicas, fn ($r) => $r['healthy']));
|
||||
|
||||
expect($totalReplicas)->toBe(3);
|
||||
expect($healthyReplicas)->toBe(2);
|
||||
|
||||
// Test load percentage calculation
|
||||
foreach ($replicas as $index => $replica) {
|
||||
$loadPercentage = ($replica['connections'] / $replica['max_connections']) * 100;
|
||||
|
||||
if ($index === 0) {
|
||||
expect($loadPercentage)->toBe(50.0); // 5/10 = 50%
|
||||
} elseif ($index === 2) {
|
||||
expect($loadPercentage)->toBe(80.0); // 8/10 = 80%
|
||||
}
|
||||
}
|
||||
|
||||
// Test success rate calculation
|
||||
foreach ($replicas as $replica) {
|
||||
$totalQueries = $replica['total_queries'];
|
||||
$failedQueries = $replica['failed_queries'];
|
||||
|
||||
if ($totalQueries === 0) {
|
||||
$successRate = 100.0;
|
||||
} else {
|
||||
$successfulQueries = $totalQueries - $failedQueries;
|
||||
$successRate = ($successfulQueries / $totalQueries) * 100;
|
||||
}
|
||||
|
||||
if ($replica['total_queries'] === 1000) {
|
||||
expect($successRate)->toBe(99.0); // 990/1000 = 99%
|
||||
} elseif ($replica['total_queries'] === 500) {
|
||||
expect($successRate)->toBe(99.0); // 495/500 = 99%
|
||||
} else {
|
||||
expect($successRate)->toBe(100.0); // No queries = 100%
|
||||
}
|
||||
}
|
||||
});
|
||||
314
tests/Framework/Database/Migration/MigrationCollectionTest.php
Normal file
314
tests/Framework/Database/Migration/MigrationCollectionTest.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\Migration;
|
||||
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
use App\Framework\Database\Migration\MigrationCollection;
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MigrationCollectionTest extends TestCase
|
||||
{
|
||||
private Migration $migration1;
|
||||
|
||||
private Migration $migration2;
|
||||
|
||||
private Migration $migration3;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->migration1 = $this->createMockMigration('2024_01_01_120000', 'First migration');
|
||||
$this->migration2 = $this->createMockMigration('2024_01_02_120000', 'Second migration');
|
||||
$this->migration3 = $this->createMockMigration('2024_01_03_120000', 'Third migration');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function constructor_creates_empty_collection(): void
|
||||
{
|
||||
$collection = new MigrationCollection();
|
||||
|
||||
$this->assertTrue($collection->isEmpty());
|
||||
$this->assertSame(0, $collection->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function constructor_accepts_variadic_migrations(): void
|
||||
{
|
||||
$collection = new MigrationCollection(
|
||||
$this->migration1,
|
||||
$this->migration2,
|
||||
$this->migration3
|
||||
);
|
||||
|
||||
$this->assertFalse($collection->isEmpty());
|
||||
$this->assertSame(3, $collection->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromArray_creates_collection_from_array(): void
|
||||
{
|
||||
$migrations = [$this->migration1, $this->migration2, $this->migration3];
|
||||
$collection = MigrationCollection::fromArray($migrations);
|
||||
|
||||
$this->assertSame(3, $collection->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getIterator_allows_foreach_iteration(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2);
|
||||
|
||||
$iterations = 0;
|
||||
foreach ($collection as $migration) {
|
||||
$this->assertInstanceOf(Migration::class, $migration);
|
||||
$iterations++;
|
||||
}
|
||||
|
||||
$this->assertSame(2, $iterations);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getByVersion_returns_migration_with_matching_version(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2);
|
||||
|
||||
$version = MigrationVersion::fromTimestamp('2024_01_01_120000');
|
||||
$result = $collection->getByVersion($version);
|
||||
|
||||
$this->assertSame($this->migration1, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getByVersion_returns_null_when_not_found(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1);
|
||||
|
||||
$version = MigrationVersion::fromTimestamp('2024_01_99_999999');
|
||||
$result = $collection->getByVersion($version);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getByVersionString_returns_migration_with_matching_version_string(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2);
|
||||
|
||||
$result = $collection->getByVersionString('2024_01_02_120000');
|
||||
|
||||
$this->assertSame($this->migration2, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getByVersionString_returns_null_when_not_found(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1);
|
||||
|
||||
$result = $collection->getByVersionString('2024_01_99_999999');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hasVersion_returns_true_when_version_exists(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2);
|
||||
|
||||
$version = MigrationVersion::fromTimestamp('2024_01_01_120000');
|
||||
$result = $collection->hasVersion($version);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hasVersion_returns_false_when_version_does_not_exist(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1);
|
||||
|
||||
$version = MigrationVersion::fromTimestamp('2024_01_99_999999');
|
||||
$result = $collection->hasVersion($version);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hasVersionString_returns_true_when_version_string_exists(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2);
|
||||
|
||||
$result = $collection->hasVersionString('2024_01_02_120000');
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hasVersionString_returns_false_when_version_string_does_not_exist(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1);
|
||||
|
||||
$result = $collection->hasVersionString('2024_01_99_999999');
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getVersions_returns_migration_version_collection(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2);
|
||||
|
||||
$versions = $collection->getVersions();
|
||||
|
||||
$this->assertSame(2, $versions->count());
|
||||
$this->assertTrue($versions->containsString('2024_01_01_120000'));
|
||||
$this->assertTrue($versions->containsString('2024_01_02_120000'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sorted_returns_collection_sorted_by_version_ascending(): void
|
||||
{
|
||||
// Create collection in reverse order
|
||||
$collection = new MigrationCollection($this->migration3, $this->migration1, $this->migration2);
|
||||
|
||||
$sorted = $collection->sorted();
|
||||
$migrations = $sorted->toArray();
|
||||
|
||||
$this->assertSame($this->migration1, $migrations[0]);
|
||||
$this->assertSame($this->migration2, $migrations[1]);
|
||||
$this->assertSame($this->migration3, $migrations[2]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sortedDescending_returns_collection_sorted_by_version_descending(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2, $this->migration3);
|
||||
|
||||
$sorted = $collection->sortedDescending();
|
||||
$migrations = $sorted->toArray();
|
||||
|
||||
$this->assertSame($this->migration3, $migrations[0]);
|
||||
$this->assertSame($this->migration2, $migrations[1]);
|
||||
$this->assertSame($this->migration1, $migrations[2]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filter_returns_collection_with_filtered_migrations(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2, $this->migration3);
|
||||
|
||||
$filtered = $collection->filter(function (Migration $migration) {
|
||||
return str_contains($migration->getDescription(), 'Second');
|
||||
});
|
||||
|
||||
$this->assertSame(1, $filtered->count());
|
||||
$this->assertSame($this->migration2, $filtered->toArray()[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function after_returns_migrations_after_specified_version(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2, $this->migration3);
|
||||
|
||||
$version = MigrationVersion::fromTimestamp('2024_01_01_120000');
|
||||
$filtered = $collection->after($version);
|
||||
|
||||
$this->assertSame(2, $filtered->count());
|
||||
$migrations = $filtered->toArray();
|
||||
$this->assertSame($this->migration2, $migrations[0]);
|
||||
$this->assertSame($this->migration3, $migrations[1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function upTo_returns_migrations_up_to_and_including_specified_version(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2, $this->migration3);
|
||||
|
||||
$version = MigrationVersion::fromTimestamp('2024_01_02_120000');
|
||||
$filtered = $collection->upTo($version);
|
||||
|
||||
$this->assertSame(2, $filtered->count());
|
||||
$migrations = $filtered->toArray();
|
||||
$this->assertSame($this->migration1, $migrations[0]);
|
||||
$this->assertSame($this->migration2, $migrations[1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function first_returns_first_migration_by_version(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration3, $this->migration1, $this->migration2);
|
||||
|
||||
$first = $collection->first();
|
||||
|
||||
$this->assertSame($this->migration1, $first);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function first_returns_null_for_empty_collection(): void
|
||||
{
|
||||
$collection = new MigrationCollection();
|
||||
|
||||
$first = $collection->first();
|
||||
|
||||
$this->assertNull($first);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function last_returns_last_migration_by_version(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration3, $this->migration2);
|
||||
|
||||
$last = $collection->last();
|
||||
|
||||
$this->assertSame($this->migration3, $last);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function last_returns_null_for_empty_collection(): void
|
||||
{
|
||||
$collection = new MigrationCollection();
|
||||
|
||||
$last = $collection->last();
|
||||
|
||||
$this->assertNull($last);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function toArray_returns_migrations_as_array(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1, $this->migration2);
|
||||
|
||||
$array = $collection->toArray();
|
||||
|
||||
$this->assertIsArray($array);
|
||||
$this->assertSame(2, count($array));
|
||||
$this->assertSame($this->migration1, $array[0]);
|
||||
$this->assertSame($this->migration2, $array[1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function collection_is_immutable(): void
|
||||
{
|
||||
$collection = new MigrationCollection($this->migration1);
|
||||
|
||||
$filtered = $collection->filter(fn () => true);
|
||||
$sorted = $collection->sorted();
|
||||
|
||||
// Original collection should be unchanged
|
||||
$this->assertSame(1, $collection->count());
|
||||
$this->assertNotSame($collection, $filtered);
|
||||
$this->assertNotSame($collection, $sorted);
|
||||
}
|
||||
|
||||
private function createMockMigration(string $version, string $description): Migration
|
||||
{
|
||||
$migration = $this->createMock(Migration::class);
|
||||
$migration->method('getVersion')
|
||||
->willReturn(MigrationVersion::fromTimestamp($version));
|
||||
$migration->method('getDescription')
|
||||
->willReturn($description);
|
||||
|
||||
return $migration;
|
||||
}
|
||||
}
|
||||
179
tests/Framework/Database/Migration/MigrationLoaderTest.php
Normal file
179
tests/Framework/Database/Migration/MigrationLoaderTest.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\Migration;
|
||||
|
||||
use App\Framework\Core\ValueObjects\ClassName;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
use App\Framework\Database\Migration\MigrationCollection;
|
||||
use App\Framework\Database\Migration\MigrationLoader;
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\Discovery\Results\AttributeRegistry;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\Results\InterfaceRegistry;
|
||||
use App\Framework\Discovery\Results\RouteRegistry;
|
||||
use App\Framework\Discovery\Results\TemplateRegistry;
|
||||
use App\Framework\Discovery\ValueObjects\InterfaceMapping;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MigrationLoaderTest extends TestCase
|
||||
{
|
||||
private Container $container;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->container = $this->createMock(Container::class);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function loadMigrations_returns_empty_collection_when_no_migrations_found(): void
|
||||
{
|
||||
// Setup interface registry with no migrations
|
||||
$interfaceRegistry = new InterfaceRegistry();
|
||||
|
||||
$discoveryRegistry = new DiscoveryRegistry(
|
||||
new AttributeRegistry(),
|
||||
$interfaceRegistry,
|
||||
new RouteRegistry(),
|
||||
new TemplateRegistry()
|
||||
);
|
||||
|
||||
$loader = new MigrationLoader($discoveryRegistry, $this->container);
|
||||
|
||||
$result = $loader->loadMigrations();
|
||||
|
||||
$this->assertInstanceOf(MigrationCollection::class, $result);
|
||||
$this->assertTrue($result->isEmpty());
|
||||
$this->assertSame(0, $result->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function loadMigrations_returns_sorted_collection_of_migrations(): void
|
||||
{
|
||||
// Create mock migration instances (in unsorted order)
|
||||
$migration1 = $this->createMockMigration('2024_01_03_120000', 'Third migration');
|
||||
$migration2 = $this->createMockMigration('2024_01_01_120000', 'First migration');
|
||||
$migration3 = $this->createMockMigration('2024_01_02_120000', 'Second migration');
|
||||
|
||||
// Create interface mappings
|
||||
$mapping1 = new InterfaceMapping(
|
||||
ClassName::create(Migration::class),
|
||||
ClassName::create('App\\Migration1'),
|
||||
FilePath::create('/path/to/migration1.php')
|
||||
);
|
||||
$mapping2 = new InterfaceMapping(
|
||||
ClassName::create(Migration::class),
|
||||
ClassName::create('App\\Migration2'),
|
||||
FilePath::create('/path/to/migration2.php')
|
||||
);
|
||||
$mapping3 = new InterfaceMapping(
|
||||
ClassName::create(Migration::class),
|
||||
ClassName::create('App\\Migration3'),
|
||||
FilePath::create('/path/to/migration3.php')
|
||||
);
|
||||
|
||||
// Setup interface registry
|
||||
$interfaceRegistry = new InterfaceRegistry();
|
||||
$interfaceRegistry->add($mapping1);
|
||||
$interfaceRegistry->add($mapping2);
|
||||
$interfaceRegistry->add($mapping3);
|
||||
|
||||
$discoveryRegistry = new DiscoveryRegistry(
|
||||
new AttributeRegistry(),
|
||||
$interfaceRegistry,
|
||||
new RouteRegistry(),
|
||||
new TemplateRegistry()
|
||||
);
|
||||
|
||||
$loader = new MigrationLoader($discoveryRegistry, $this->container);
|
||||
|
||||
// Setup container to return migration instances
|
||||
$this->container->method('get')
|
||||
->willReturnMap([
|
||||
['App\\Migration1', $migration1],
|
||||
['App\\Migration2', $migration2],
|
||||
['App\\Migration3', $migration3],
|
||||
]);
|
||||
|
||||
$result = $loader->loadMigrations();
|
||||
|
||||
$this->assertInstanceOf(MigrationCollection::class, $result);
|
||||
$this->assertSame(3, $result->count());
|
||||
|
||||
// Verify migrations are sorted by version
|
||||
$migrations = $result->toArray();
|
||||
$this->assertSame($migration2, $migrations[0]); // 2024_01_01_120000
|
||||
$this->assertSame($migration3, $migrations[1]); // 2024_01_02_120000
|
||||
$this->assertSame($migration1, $migrations[2]); // 2024_01_03_120000
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function loadMigrations_uses_discovery_registry_to_find_migration_implementations(): void
|
||||
{
|
||||
$interfaceRegistry = new InterfaceRegistry();
|
||||
|
||||
$discoveryRegistry = new DiscoveryRegistry(
|
||||
new AttributeRegistry(),
|
||||
$interfaceRegistry,
|
||||
new RouteRegistry(),
|
||||
new TemplateRegistry()
|
||||
);
|
||||
|
||||
$loader = new MigrationLoader($discoveryRegistry, $this->container);
|
||||
|
||||
$result = $loader->loadMigrations();
|
||||
|
||||
// Verify that the discovery registry was used by checking we get an empty collection
|
||||
$this->assertInstanceOf(MigrationCollection::class, $result);
|
||||
$this->assertTrue($result->isEmpty());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function loadMigrations_uses_container_to_instantiate_migrations(): void
|
||||
{
|
||||
$migration = $this->createMockMigration('2024_01_01_120000', 'Test migration');
|
||||
|
||||
$mapping = new InterfaceMapping(
|
||||
ClassName::create(Migration::class),
|
||||
ClassName::create('App\\TestMigration'),
|
||||
FilePath::create('/path/to/test_migration.php')
|
||||
);
|
||||
|
||||
$interfaceRegistry = new InterfaceRegistry();
|
||||
$interfaceRegistry->add($mapping);
|
||||
|
||||
$discoveryRegistry = new DiscoveryRegistry(
|
||||
new AttributeRegistry(),
|
||||
$interfaceRegistry,
|
||||
new RouteRegistry(),
|
||||
new TemplateRegistry()
|
||||
);
|
||||
|
||||
$loader = new MigrationLoader($discoveryRegistry, $this->container);
|
||||
|
||||
$this->container->expects($this->once())
|
||||
->method('get')
|
||||
->with('App\\TestMigration')
|
||||
->willReturn($migration);
|
||||
|
||||
$result = $loader->loadMigrations();
|
||||
|
||||
$this->assertSame(1, $result->count());
|
||||
$this->assertSame($migration, $result->toArray()[0]);
|
||||
}
|
||||
|
||||
private function createMockMigration(string $version, string $description): Migration
|
||||
{
|
||||
$migration = $this->createMock(Migration::class);
|
||||
$migration->method('getVersion')
|
||||
->willReturn(MigrationVersion::fromTimestamp($version));
|
||||
$migration->method('getDescription')
|
||||
->willReturn($description);
|
||||
|
||||
return $migration;
|
||||
}
|
||||
}
|
||||
359
tests/Framework/Database/Migration/MigrationRunnerTest.php
Normal file
359
tests/Framework/Database/Migration/MigrationRunnerTest.php
Normal file
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
use App\Framework\Database\Migration\Migration;
|
||||
use App\Framework\Database\Migration\MigrationRunner;
|
||||
use App\Framework\Database\ResultInterface;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->connection = new TestConnection();
|
||||
$this->migrationRunner = new MigrationRunner($this->connection, 'test_migrations');
|
||||
});
|
||||
|
||||
test('constructor creates migrations table', function () {
|
||||
// Constructor should have already been called in beforeEach
|
||||
$queries = $this->connection->getQueries();
|
||||
|
||||
expect($queries)->toHaveCount(1)
|
||||
->and($queries[0]['type'])->toBe('execute')
|
||||
->and($queries[0]['sql'])->toContain('CREATE TABLE IF NOT EXISTS test_migrations');
|
||||
});
|
||||
|
||||
test('migrate runs pending migrations', function () {
|
||||
// Mock migration
|
||||
$migration = new TestMigration();
|
||||
$migrationData = (object) [
|
||||
'version' => '2024_01_01_000000',
|
||||
'description' => 'Test Migration',
|
||||
'instance' => $migration,
|
||||
];
|
||||
|
||||
// Set no applied migrations initially
|
||||
$this->connection->setAppliedMigrations([]);
|
||||
|
||||
$result = $this->migrationRunner->migrate([$migrationData]);
|
||||
|
||||
expect($result)->toContain('2024_01_01_000000');
|
||||
expect($migration->wasExecuted())->toBeTrue();
|
||||
|
||||
// Verify the migration was recorded
|
||||
$queries = $this->connection->getQueries();
|
||||
$insertQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) =>
|
||||
$q['type'] === 'execute' && str_contains($q['sql'], 'INSERT INTO test_migrations')
|
||||
);
|
||||
expect($insertQueries)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('migrate skips already applied migrations', function () {
|
||||
$migration = new TestMigration();
|
||||
$migrationData = (object) [
|
||||
'version' => '2024_01_01_000000',
|
||||
'description' => 'Test Migration',
|
||||
'instance' => $migration,
|
||||
];
|
||||
|
||||
// Set migration as already applied
|
||||
$this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]);
|
||||
|
||||
$result = $this->migrationRunner->migrate([$migrationData]);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
expect($migration->wasExecuted())->toBeFalse();
|
||||
});
|
||||
|
||||
test('migrate rolls back on failure', function () {
|
||||
// Mock failing migration
|
||||
$failingMigration = new FailingTestMigration();
|
||||
$migrationData = (object) [
|
||||
'version' => '2024_01_01_000000',
|
||||
'description' => 'Failing Migration',
|
||||
'instance' => $failingMigration,
|
||||
];
|
||||
|
||||
// Set no applied migrations initially
|
||||
$this->connection->setAppliedMigrations([]);
|
||||
$this->connection->setShouldFail(true);
|
||||
|
||||
expect(fn () => $this->migrationRunner->migrate([$migrationData]))
|
||||
->toThrow(DatabaseException::class);
|
||||
|
||||
// Verify transaction was used (inTransaction was called)
|
||||
expect($this->connection->inTransaction())->toBeFalse(); // Should be rolled back
|
||||
});
|
||||
|
||||
test('rollback reverts applied migration', function () {
|
||||
$migration = new TestMigration();
|
||||
$migrationData = (object) [
|
||||
'version' => '2024_01_01_000000',
|
||||
'description' => 'Test Migration',
|
||||
'instance' => $migration,
|
||||
];
|
||||
|
||||
// Set migration as already applied
|
||||
$this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]);
|
||||
|
||||
$result = $this->migrationRunner->rollback([$migrationData], 1);
|
||||
|
||||
expect($result)->toContain('2024_01_01_000000');
|
||||
expect($migration->wasRolledBack())->toBeTrue();
|
||||
|
||||
// Verify the migration record was deleted
|
||||
$queries = $this->connection->getQueries();
|
||||
$deleteQueries = array_filter(
|
||||
$queries,
|
||||
fn ($q) =>
|
||||
$q['type'] === 'execute' && str_contains($q['sql'], 'DELETE FROM test_migrations')
|
||||
);
|
||||
expect($deleteQueries)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('get status returns migration status', function () {
|
||||
$migration1Data = (object) [
|
||||
'version' => '2024_01_01_000000',
|
||||
'description' => 'Applied Migration',
|
||||
'instance' => new TestMigration(),
|
||||
];
|
||||
$migration2Data = (object) [
|
||||
'version' => '2024_01_02_000000',
|
||||
'description' => 'Pending Migration',
|
||||
'instance' => new TestMigration(),
|
||||
];
|
||||
|
||||
// Set only first migration as applied
|
||||
$this->connection->setAppliedMigrations([['version' => '2024_01_01_000000']]);
|
||||
|
||||
$status = $this->migrationRunner->getStatus([$migration1Data, $migration2Data]);
|
||||
|
||||
expect($status)->toHaveCount(2)
|
||||
->and($status[0]['applied'])->toBeTrue()
|
||||
->and($status[1]['applied'])->toBeFalse();
|
||||
});
|
||||
|
||||
// Test fixtures
|
||||
class TestMigration implements Migration
|
||||
{
|
||||
private bool $executed = false;
|
||||
|
||||
private bool $rolledBack = false;
|
||||
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
$this->executed = true;
|
||||
// Simulate migration execution
|
||||
$connection->execute('CREATE TABLE test_table (id INT)');
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
$this->rolledBack = true;
|
||||
// Simulate migration rollback
|
||||
$connection->execute('DROP TABLE test_table');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Test Migration';
|
||||
}
|
||||
|
||||
public function getVersion(): \App\Framework\Database\Migration\MigrationVersion
|
||||
{
|
||||
return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_01_000000');
|
||||
}
|
||||
|
||||
public function wasExecuted(): bool
|
||||
{
|
||||
return $this->executed;
|
||||
}
|
||||
|
||||
public function wasRolledBack(): bool
|
||||
{
|
||||
return $this->rolledBack;
|
||||
}
|
||||
}
|
||||
|
||||
class FailingTestMigration implements Migration
|
||||
{
|
||||
public function up(ConnectionInterface $connection): void
|
||||
{
|
||||
throw new \Exception('Migration failed');
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface $connection): void
|
||||
{
|
||||
// No-op
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Failing Migration';
|
||||
}
|
||||
|
||||
public function getVersion(): \App\Framework\Database\Migration\MigrationVersion
|
||||
{
|
||||
return \App\Framework\Database\Migration\MigrationVersion::fromTimestamp('2024_01_01_000000');
|
||||
}
|
||||
}
|
||||
|
||||
class TestConnection implements ConnectionInterface
|
||||
{
|
||||
private array $queries = [];
|
||||
|
||||
private array $appliedMigrations = [];
|
||||
|
||||
private bool $inTransaction = false;
|
||||
|
||||
private bool $shouldFail = false;
|
||||
|
||||
public function setAppliedMigrations(array $migrations): void
|
||||
{
|
||||
$this->appliedMigrations = $migrations;
|
||||
}
|
||||
|
||||
public function setShouldFail(bool $fail): void
|
||||
{
|
||||
$this->shouldFail = $fail;
|
||||
}
|
||||
|
||||
public function execute(string $sql, array $parameters = []): int
|
||||
{
|
||||
$this->queries[] = ['type' => 'execute', 'sql' => $sql, 'params' => $parameters];
|
||||
|
||||
if ($this->shouldFail && strpos($sql, 'INSERT INTO') !== false) {
|
||||
throw new DatabaseException('Simulated database failure');
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function query(string $sql, array $parameters = []): ResultInterface
|
||||
{
|
||||
$this->queries[] = ['type' => 'query', 'sql' => $sql, 'params' => $parameters];
|
||||
|
||||
return new TestResult($this->appliedMigrations);
|
||||
}
|
||||
|
||||
public function queryOne(string $sql, array $parameters = []): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function queryColumn(string $sql, array $parameters = []): array
|
||||
{
|
||||
$this->queries[] = ['type' => 'queryColumn', 'sql' => $sql, 'params' => $parameters];
|
||||
|
||||
// Return the versions from applied migrations
|
||||
return array_column($this->appliedMigrations, 'version');
|
||||
}
|
||||
|
||||
public function queryScalar(string $sql, array $parameters = []): mixed
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function beginTransaction(): void
|
||||
{
|
||||
$this->inTransaction = true;
|
||||
}
|
||||
|
||||
public function commit(): void
|
||||
{
|
||||
$this->inTransaction = false;
|
||||
}
|
||||
|
||||
public function rollback(): void
|
||||
{
|
||||
$this->inTransaction = false;
|
||||
}
|
||||
|
||||
public function inTransaction(): bool
|
||||
{
|
||||
return $this->inTransaction;
|
||||
}
|
||||
|
||||
public function lastInsertId(): string
|
||||
{
|
||||
return '1';
|
||||
}
|
||||
|
||||
public function getPdo(): \PDO
|
||||
{
|
||||
return new class () extends \PDO {
|
||||
public function __construct()
|
||||
{
|
||||
// Skip parent constructor to avoid actual DB connection
|
||||
}
|
||||
|
||||
public function getAttribute(int $attribute): mixed
|
||||
{
|
||||
if ($attribute === \PDO::ATTR_DRIVER_NAME) {
|
||||
return 'sqlite';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function getQueries(): array
|
||||
{
|
||||
return $this->queries;
|
||||
}
|
||||
}
|
||||
|
||||
class TestResult implements ResultInterface
|
||||
{
|
||||
private array $data;
|
||||
|
||||
private int $position = 0;
|
||||
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function fetch(): ?array
|
||||
{
|
||||
if ($this->position >= count($this->data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->data[$this->position++];
|
||||
}
|
||||
|
||||
public function fetchAll(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function fetchColumn(int $column = 0): array
|
||||
{
|
||||
return array_column($this->data, $column);
|
||||
}
|
||||
|
||||
public function fetchScalar(): mixed
|
||||
{
|
||||
$row = $this->fetch();
|
||||
|
||||
return $row ? reset($row) : null;
|
||||
}
|
||||
|
||||
public function rowCount(): int
|
||||
{
|
||||
return count($this->data);
|
||||
}
|
||||
|
||||
public function getIterator(): \Iterator
|
||||
{
|
||||
return new \ArrayIterator($this->data);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\Schema\Comparison;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\ResultInterface;
|
||||
use App\Framework\Database\Schema\Comparison\SchemaComparator;
|
||||
use App\Framework\Database\Schema\Comparison\SchemaDifference;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->sourceConnection = Mockery::mock(ConnectionInterface::class);
|
||||
$this->targetConnection = Mockery::mock(ConnectionInterface::class);
|
||||
$this->comparator = new SchemaComparator($this->sourceConnection, $this->targetConnection);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
test('compares schemas with different tables', function () {
|
||||
// Mock source tables
|
||||
$sourceTables = Mockery::mock(ResultInterface::class);
|
||||
$sourceTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
['table_name' => 'posts'],
|
||||
['table_name' => 'comments'],
|
||||
]);
|
||||
|
||||
// Mock target tables
|
||||
$targetTables = Mockery::mock(ResultInterface::class);
|
||||
$targetTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
['table_name' => 'posts'],
|
||||
['table_name' => 'categories'], // Different table
|
||||
]);
|
||||
|
||||
// Mock table structure queries
|
||||
$this->sourceConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->targetConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($sourceTables);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($targetTables);
|
||||
|
||||
// Mock column queries for each table
|
||||
$this->mockTableStructure('users', true, true);
|
||||
$this->mockTableStructure('posts', true, true);
|
||||
$this->mockTableStructure('comments', true, false);
|
||||
$this->mockTableStructure('categories', false, true);
|
||||
|
||||
$difference = $this->comparator->compare();
|
||||
|
||||
expect($difference)->toBeInstanceOf(SchemaDifference::class);
|
||||
expect($difference->hasDifferences())->toBeTrue();
|
||||
expect($difference->missingTables)->toHaveCount(1);
|
||||
expect($difference->extraTables)->toHaveCount(1);
|
||||
expect(array_keys($difference->missingTables))->toBe(['comments']);
|
||||
expect(array_keys($difference->extraTables))->toBe(['categories']);
|
||||
});
|
||||
|
||||
test('compares schemas with identical tables', function () {
|
||||
// Mock source tables
|
||||
$sourceTables = Mockery::mock(ResultInterface::class);
|
||||
$sourceTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
['table_name' => 'posts'],
|
||||
]);
|
||||
|
||||
// Mock target tables
|
||||
$targetTables = Mockery::mock(ResultInterface::class);
|
||||
$targetTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
['table_name' => 'posts'],
|
||||
]);
|
||||
|
||||
// Mock table structure queries
|
||||
$this->sourceConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->targetConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($sourceTables);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($targetTables);
|
||||
|
||||
// Mock identical column structure for each table
|
||||
$this->mockIdenticalTableStructure('users');
|
||||
$this->mockIdenticalTableStructure('posts');
|
||||
|
||||
$difference = $this->comparator->compare();
|
||||
|
||||
expect($difference)->toBeInstanceOf(SchemaDifference::class);
|
||||
expect($difference->hasDifferences())->toBeFalse();
|
||||
expect($difference->missingTables)->toBeEmpty();
|
||||
expect($difference->extraTables)->toBeEmpty();
|
||||
expect($difference->tableDifferences)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('compares schemas with different column definitions', function () {
|
||||
// Mock source tables
|
||||
$sourceTables = Mockery::mock(ResultInterface::class);
|
||||
$sourceTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
]);
|
||||
|
||||
// Mock target tables
|
||||
$targetTables = Mockery::mock(ResultInterface::class);
|
||||
$targetTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
]);
|
||||
|
||||
// Mock table structure queries
|
||||
$this->sourceConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->targetConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($sourceTables);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($targetTables);
|
||||
|
||||
// Mock source columns
|
||||
$sourceColumns = Mockery::mock(ResultInterface::class);
|
||||
$sourceColumns->shouldReceive('fetchAll')->andReturn([
|
||||
[
|
||||
'column_name' => 'id',
|
||||
'data_type' => 'integer',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => 'nextval(\'users_id_seq\'::regclass)',
|
||||
],
|
||||
[
|
||||
'column_name' => 'name',
|
||||
'data_type' => 'character varying',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => null,
|
||||
],
|
||||
[
|
||||
'column_name' => 'email',
|
||||
'data_type' => 'character varying',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock target columns with differences
|
||||
$targetColumns = Mockery::mock(ResultInterface::class);
|
||||
$targetColumns->shouldReceive('fetchAll')->andReturn([
|
||||
[
|
||||
'column_name' => 'id',
|
||||
'data_type' => 'integer',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => 'nextval(\'users_id_seq\'::regclass)',
|
||||
],
|
||||
[
|
||||
'column_name' => 'name',
|
||||
'data_type' => 'character varying',
|
||||
'is_nullable' => 'YES', // Changed to nullable
|
||||
'column_default' => null,
|
||||
],
|
||||
[
|
||||
'column_name' => 'username', // Different column
|
||||
'data_type' => 'character varying',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.columns.*users/'), [])
|
||||
->andReturn($sourceColumns);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.columns.*users/'), [])
|
||||
->andReturn($targetColumns);
|
||||
|
||||
// Mock empty indexes and foreign keys
|
||||
$this->mockEmptyIndexes('users');
|
||||
$this->mockEmptyForeignKeys('users');
|
||||
|
||||
$difference = $this->comparator->compare();
|
||||
|
||||
expect($difference)->toBeInstanceOf(SchemaDifference::class);
|
||||
expect($difference->hasDifferences())->toBeTrue();
|
||||
expect($difference->tableDifferences)->toHaveCount(1);
|
||||
expect($difference->tableDifferences['users']->missingColumns)->toHaveKey('email');
|
||||
expect($difference->tableDifferences['users']->extraColumns)->toHaveKey('username');
|
||||
expect($difference->tableDifferences['users']->modifiedColumns)->toHaveKey('name');
|
||||
});
|
||||
|
||||
test('compares schemas with different indexes', function () {
|
||||
// Mock source tables
|
||||
$sourceTables = Mockery::mock(ResultInterface::class);
|
||||
$sourceTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
]);
|
||||
|
||||
// Mock target tables
|
||||
$targetTables = Mockery::mock(ResultInterface::class);
|
||||
$targetTables->shouldReceive('fetchAll')->andReturn([
|
||||
['table_name' => 'users'],
|
||||
]);
|
||||
|
||||
// Mock table structure queries
|
||||
$this->sourceConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->targetConnection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('pgsql');
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($sourceTables);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.tables/'), [])
|
||||
->andReturn($targetTables);
|
||||
|
||||
// Mock identical columns
|
||||
$this->mockIdenticalColumns('users');
|
||||
|
||||
// Mock source indexes
|
||||
$sourceIndexes = Mockery::mock(ResultInterface::class);
|
||||
$sourceIndexes->shouldReceive('fetchAll')->andReturn([
|
||||
[
|
||||
'indexname' => 'users_pkey',
|
||||
'indexdef' => 'CREATE UNIQUE INDEX users_pkey ON users USING btree (id)',
|
||||
],
|
||||
[
|
||||
'indexname' => 'users_email_idx',
|
||||
'indexdef' => 'CREATE UNIQUE INDEX users_email_idx ON users USING btree (email)',
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock target indexes with differences
|
||||
$targetIndexes = Mockery::mock(ResultInterface::class);
|
||||
$targetIndexes->shouldReceive('fetchAll')->andReturn([
|
||||
[
|
||||
'indexname' => 'users_pkey',
|
||||
'indexdef' => 'CREATE UNIQUE INDEX users_pkey ON users USING btree (id)',
|
||||
],
|
||||
[
|
||||
'indexname' => 'users_name_idx', // Different index
|
||||
'indexdef' => 'CREATE INDEX users_name_idx ON users USING btree (name)',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/pg_indexes.*users/'), [])
|
||||
->andReturn($sourceIndexes);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/pg_indexes.*users/'), [])
|
||||
->andReturn($targetIndexes);
|
||||
|
||||
// Mock empty foreign keys
|
||||
$this->mockEmptyForeignKeys('users');
|
||||
|
||||
$difference = $this->comparator->compare();
|
||||
|
||||
expect($difference)->toBeInstanceOf(SchemaDifference::class);
|
||||
expect($difference->hasDifferences())->toBeTrue();
|
||||
expect($difference->tableDifferences)->toHaveCount(1);
|
||||
expect($difference->tableDifferences['users']->missingIndexes)->toHaveKey('users_email_idx');
|
||||
expect($difference->tableDifferences['users']->extraIndexes)->toHaveKey('users_name_idx');
|
||||
});
|
||||
|
||||
// Helper functions for tests
|
||||
beforeEach(function () {
|
||||
// Define helper methods in the test context
|
||||
$this->mockTableStructure = function (string $tableName, bool $inSource, bool $inTarget): void {
|
||||
if ($inSource) {
|
||||
$sourceColumns = Mockery::mock(ResultInterface::class);
|
||||
$sourceColumns->shouldReceive('fetchAll')->andReturn([
|
||||
[
|
||||
'column_name' => 'id',
|
||||
'data_type' => 'integer',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => 'nextval(\'' . $tableName . '_id_seq\'::regclass)',
|
||||
],
|
||||
[
|
||||
'column_name' => 'name',
|
||||
'data_type' => 'character varying',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.columns.*' . $tableName . '/'), [])
|
||||
->andReturn($sourceColumns);
|
||||
|
||||
$this->mockEmptyIndexes($tableName, true, false);
|
||||
$this->mockEmptyForeignKeys($tableName, true, false);
|
||||
}
|
||||
|
||||
if ($inTarget) {
|
||||
$targetColumns = Mockery::mock(ResultInterface::class);
|
||||
$targetColumns->shouldReceive('fetchAll')->andReturn([
|
||||
[
|
||||
'column_name' => 'id',
|
||||
'data_type' => 'integer',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => 'nextval(\'' . $tableName . '_id_seq\'::regclass)',
|
||||
],
|
||||
[
|
||||
'column_name' => 'name',
|
||||
'data_type' => 'character varying',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.columns.*' . $tableName . '/'), [])
|
||||
->andReturn($targetColumns);
|
||||
|
||||
$this->mockEmptyIndexes($tableName, false, true);
|
||||
$this->mockEmptyForeignKeys($tableName, false, true);
|
||||
}
|
||||
};
|
||||
|
||||
$this->mockIdenticalTableStructure = function (string $tableName): void {
|
||||
$this->mockIdenticalColumns($tableName);
|
||||
$this->mockEmptyIndexes($tableName);
|
||||
$this->mockEmptyForeignKeys($tableName);
|
||||
};
|
||||
|
||||
$this->mockIdenticalColumns = function (string $tableName): void {
|
||||
$columns = Mockery::mock(ResultInterface::class);
|
||||
$columns->shouldReceive('fetchAll')->andReturn([
|
||||
[
|
||||
'column_name' => 'id',
|
||||
'data_type' => 'integer',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => 'nextval(\'' . $tableName . '_id_seq\'::regclass)',
|
||||
],
|
||||
[
|
||||
'column_name' => 'name',
|
||||
'data_type' => 'character varying',
|
||||
'is_nullable' => 'NO',
|
||||
'column_default' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.columns.*' . $tableName . '/'), [])
|
||||
->andReturn($columns);
|
||||
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.columns.*' . $tableName . '/'), [])
|
||||
->andReturn($columns);
|
||||
};
|
||||
|
||||
$this->mockEmptyIndexes = function (string $tableName, bool $source = true, bool $target = true): void {
|
||||
$emptyIndexes = Mockery::mock(ResultInterface::class);
|
||||
$emptyIndexes->shouldReceive('fetchAll')->andReturn([]);
|
||||
|
||||
if ($source) {
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/pg_indexes.*' . $tableName . '/'), [])
|
||||
->andReturn($emptyIndexes);
|
||||
}
|
||||
|
||||
if ($target) {
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/pg_indexes.*' . $tableName . '/'), [])
|
||||
->andReturn($emptyIndexes);
|
||||
}
|
||||
};
|
||||
|
||||
$this->mockEmptyForeignKeys = function (string $tableName, bool $source = true, bool $target = true): void {
|
||||
$emptyForeignKeys = Mockery::mock(ResultInterface::class);
|
||||
$emptyForeignKeys->shouldReceive('fetchAll')->andReturn([]);
|
||||
|
||||
if ($source) {
|
||||
$this->sourceConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.table_constraints.*' . $tableName . '/'), [])
|
||||
->andReturn($emptyForeignKeys);
|
||||
}
|
||||
|
||||
if ($target) {
|
||||
$this->targetConnection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/information_schema.table_constraints.*' . $tableName . '/'), [])
|
||||
->andReturn($emptyForeignKeys);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\Schema\Comparison;
|
||||
|
||||
use App\Framework\Database\Schema\Comparison\SchemaDifference;
|
||||
use App\Framework\Database\Schema\Comparison\TableDifference;
|
||||
|
||||
test('creates a schema difference with missing and extra tables', function () {
|
||||
$missingTables = [
|
||||
'users' => [
|
||||
'columns' => [
|
||||
'id' => ['type' => 'integer', 'nullable' => false],
|
||||
'name' => ['type' => 'varchar', 'nullable' => false],
|
||||
'email' => ['type' => 'varchar', 'nullable' => false],
|
||||
],
|
||||
],
|
||||
'posts' => [
|
||||
'columns' => [
|
||||
'id' => ['type' => 'integer', 'nullable' => false],
|
||||
'title' => ['type' => 'varchar', 'nullable' => false],
|
||||
'content' => ['type' => 'text', 'nullable' => true],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$extraTables = [
|
||||
'categories' => [
|
||||
'columns' => [
|
||||
'id' => ['type' => 'integer', 'nullable' => false],
|
||||
'name' => ['type' => 'varchar', 'nullable' => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$difference = new SchemaDifference(
|
||||
'source_schema',
|
||||
'target_schema',
|
||||
$missingTables,
|
||||
$extraTables,
|
||||
[]
|
||||
);
|
||||
|
||||
expect($difference->sourceSchema)->toBe('source_schema');
|
||||
expect($difference->targetSchema)->toBe('target_schema');
|
||||
expect($difference->missingTables)->toBe($missingTables);
|
||||
expect($difference->extraTables)->toBe($extraTables);
|
||||
expect($difference->tableDifferences)->toBeEmpty();
|
||||
expect($difference->hasDifferences())->toBeTrue();
|
||||
});
|
||||
|
||||
test('creates a schema difference with table differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
['email' => ['type' => 'varchar', 'nullable' => false]], // missing columns
|
||||
['username' => ['type' => 'varchar', 'nullable' => false]], // extra columns
|
||||
[
|
||||
'name' => [
|
||||
'source' => ['type' => 'varchar', 'nullable' => false],
|
||||
'target' => ['type' => 'varchar', 'nullable' => true],
|
||||
],
|
||||
], // modified columns
|
||||
[], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
$difference = new SchemaDifference(
|
||||
'source_schema',
|
||||
'target_schema',
|
||||
[], // missing tables
|
||||
[], // extra tables
|
||||
['users' => $tableDifference]
|
||||
);
|
||||
|
||||
expect($difference->sourceSchema)->toBe('source_schema');
|
||||
expect($difference->targetSchema)->toBe('target_schema');
|
||||
expect($difference->missingTables)->toBeEmpty();
|
||||
expect($difference->extraTables)->toBeEmpty();
|
||||
expect($difference->tableDifferences)->toHaveCount(1);
|
||||
expect($difference->tableDifferences['users'])->toBe($tableDifference);
|
||||
expect($difference->hasDifferences())->toBeTrue();
|
||||
});
|
||||
|
||||
test('creates a schema difference with no differences', function () {
|
||||
$difference = new SchemaDifference(
|
||||
'source_schema',
|
||||
'target_schema',
|
||||
[], // missing tables
|
||||
[], // extra tables
|
||||
[] // table differences
|
||||
);
|
||||
|
||||
expect($difference->sourceSchema)->toBe('source_schema');
|
||||
expect($difference->targetSchema)->toBe('target_schema');
|
||||
expect($difference->missingTables)->toBeEmpty();
|
||||
expect($difference->extraTables)->toBeEmpty();
|
||||
expect($difference->tableDifferences)->toBeEmpty();
|
||||
expect($difference->hasDifferences())->toBeFalse();
|
||||
});
|
||||
|
||||
test('gets summary of differences', function () {
|
||||
$tableDifference1 = new TableDifference(
|
||||
'users',
|
||||
['email' => ['type' => 'varchar', 'nullable' => false]], // missing columns
|
||||
['username' => ['type' => 'varchar', 'nullable' => false]], // extra columns
|
||||
[], // modified columns
|
||||
[], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
$tableDifference2 = new TableDifference(
|
||||
'posts',
|
||||
[], // missing columns
|
||||
[], // extra columns
|
||||
[], // modified columns
|
||||
['idx_posts_title' => ['type' => 'index', 'columns' => ['title']]], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
$difference = new SchemaDifference(
|
||||
'source_schema',
|
||||
'target_schema',
|
||||
['comments' => []], // missing tables
|
||||
['categories' => []], // extra tables
|
||||
[
|
||||
'users' => $tableDifference1,
|
||||
'posts' => $tableDifference2,
|
||||
]
|
||||
);
|
||||
|
||||
$summary = $difference->getSummary();
|
||||
|
||||
expect($summary)->toBeArray();
|
||||
expect($summary['missing_tables'])->toBe(1);
|
||||
expect($summary['extra_tables'])->toBe(1);
|
||||
expect($summary['modified_tables'])->toBe(2);
|
||||
expect($summary['total_differences'])->toBe(4);
|
||||
});
|
||||
|
||||
test('gets description of differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
['email' => ['type' => 'varchar', 'nullable' => false]], // missing columns
|
||||
['username' => ['type' => 'varchar', 'nullable' => false]], // extra columns
|
||||
[
|
||||
'name' => [
|
||||
'source' => ['type' => 'varchar', 'nullable' => false],
|
||||
'target' => ['type' => 'varchar', 'nullable' => true],
|
||||
],
|
||||
], // modified columns
|
||||
[], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
$difference = new SchemaDifference(
|
||||
'source_schema',
|
||||
'target_schema',
|
||||
['comments' => []], // missing tables
|
||||
['categories' => []], // extra tables
|
||||
['users' => $tableDifference]
|
||||
);
|
||||
|
||||
$description = $difference->getDescription();
|
||||
|
||||
expect($description)->toBeString();
|
||||
expect($description)->toContain('Schema Differences');
|
||||
expect($description)->toContain('Missing Tables');
|
||||
expect($description)->toContain('comments');
|
||||
expect($description)->toContain('Extra Tables');
|
||||
expect($description)->toContain('categories');
|
||||
expect($description)->toContain('Table Differences');
|
||||
expect($description)->toContain('users');
|
||||
});
|
||||
|
||||
test('generates migration code from differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
['email' => ['type' => 'varchar', 'nullable' => false]], // missing columns
|
||||
['username' => ['type' => 'varchar', 'nullable' => false]], // extra columns
|
||||
[
|
||||
'name' => [
|
||||
'source' => ['type' => 'varchar', 'nullable' => false],
|
||||
'target' => ['type' => 'varchar', 'nullable' => true],
|
||||
],
|
||||
], // modified columns
|
||||
[], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
$difference = new SchemaDifference(
|
||||
'source_schema',
|
||||
'target_schema',
|
||||
['comments' => [
|
||||
'columns' => [
|
||||
'id' => ['type' => 'integer', 'nullable' => false],
|
||||
'content' => ['type' => 'text', 'nullable' => false],
|
||||
'post_id' => ['type' => 'integer', 'nullable' => false],
|
||||
],
|
||||
]], // missing tables
|
||||
['categories' => []], // extra tables
|
||||
['users' => $tableDifference]
|
||||
);
|
||||
|
||||
$migrationCode = $difference->generateMigrationCode('UpdateSchema');
|
||||
|
||||
expect($migrationCode)->toBeString();
|
||||
expect($migrationCode)->toContain('class UpdateSchema extends AbstractMigration');
|
||||
expect($migrationCode)->toContain('public function up(ConnectionInterface $connection)');
|
||||
expect($migrationCode)->toContain('public function down(ConnectionInterface $connection)');
|
||||
expect($migrationCode)->toContain('$schema->create(\'comments\'');
|
||||
expect($migrationCode)->toContain('$schema->table(\'users\'');
|
||||
expect($migrationCode)->toContain('$schema->dropIfExists(\'categories\')');
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\Schema\Comparison;
|
||||
|
||||
use App\Framework\Database\Schema\Comparison\TableDifference;
|
||||
|
||||
test('creates a table difference with column differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
[
|
||||
'email' => ['type' => 'varchar', 'nullable' => false],
|
||||
'created_at' => ['type' => 'timestamp', 'nullable' => false],
|
||||
], // missing columns
|
||||
[
|
||||
'username' => ['type' => 'varchar', 'nullable' => false],
|
||||
'last_login' => ['type' => 'timestamp', 'nullable' => true],
|
||||
], // extra columns
|
||||
[
|
||||
'name' => [
|
||||
'source' => ['type' => 'varchar', 'nullable' => false],
|
||||
'target' => ['type' => 'varchar', 'nullable' => true],
|
||||
],
|
||||
], // modified columns
|
||||
[], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
expect($tableDifference->tableName)->toBe('users');
|
||||
expect($tableDifference->missingColumns)->toHaveCount(2);
|
||||
expect($tableDifference->extraColumns)->toHaveCount(2);
|
||||
expect($tableDifference->modifiedColumns)->toHaveCount(1);
|
||||
expect($tableDifference->missingIndexes)->toBeEmpty();
|
||||
expect($tableDifference->extraIndexes)->toBeEmpty();
|
||||
expect($tableDifference->modifiedIndexes)->toBeEmpty();
|
||||
expect($tableDifference->missingForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->extraForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->modifiedForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->hasDifferences())->toBeTrue();
|
||||
});
|
||||
|
||||
test('creates a table difference with index differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
[], // missing columns
|
||||
[], // extra columns
|
||||
[], // modified columns
|
||||
[
|
||||
'idx_users_email' => [
|
||||
'type' => 'unique',
|
||||
'columns' => ['email'],
|
||||
],
|
||||
'idx_users_name' => [
|
||||
'type' => 'index',
|
||||
'columns' => ['name'],
|
||||
],
|
||||
], // missing indexes
|
||||
[
|
||||
'idx_users_username' => [
|
||||
'type' => 'unique',
|
||||
'columns' => ['username'],
|
||||
],
|
||||
], // extra indexes
|
||||
[
|
||||
'idx_users_created_at' => [
|
||||
'source' => [
|
||||
'type' => 'index',
|
||||
'columns' => ['created_at'],
|
||||
],
|
||||
'target' => [
|
||||
'type' => 'index',
|
||||
'columns' => ['created_at', 'updated_at'],
|
||||
],
|
||||
],
|
||||
], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
expect($tableDifference->tableName)->toBe('users');
|
||||
expect($tableDifference->missingColumns)->toBeEmpty();
|
||||
expect($tableDifference->extraColumns)->toBeEmpty();
|
||||
expect($tableDifference->modifiedColumns)->toBeEmpty();
|
||||
expect($tableDifference->missingIndexes)->toHaveCount(2);
|
||||
expect($tableDifference->extraIndexes)->toHaveCount(1);
|
||||
expect($tableDifference->modifiedIndexes)->toHaveCount(1);
|
||||
expect($tableDifference->missingForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->extraForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->modifiedForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->hasDifferences())->toBeTrue();
|
||||
});
|
||||
|
||||
test('creates a table difference with foreign key differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'posts',
|
||||
[], // missing columns
|
||||
[], // extra columns
|
||||
[], // modified columns
|
||||
[], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[
|
||||
'fk_posts_user_id' => [
|
||||
'columns' => ['user_id'],
|
||||
'referenced_table' => 'users',
|
||||
'referenced_columns' => ['id'],
|
||||
'update_rule' => 'CASCADE',
|
||||
'delete_rule' => 'CASCADE',
|
||||
],
|
||||
], // missing foreign keys
|
||||
[
|
||||
'fk_posts_category_id' => [
|
||||
'columns' => ['category_id'],
|
||||
'referenced_table' => 'categories',
|
||||
'referenced_columns' => ['id'],
|
||||
'update_rule' => 'CASCADE',
|
||||
'delete_rule' => 'CASCADE',
|
||||
],
|
||||
], // extra foreign keys
|
||||
[
|
||||
'fk_posts_parent_id' => [
|
||||
'source' => [
|
||||
'columns' => ['parent_id'],
|
||||
'referenced_table' => 'posts',
|
||||
'referenced_columns' => ['id'],
|
||||
'update_rule' => 'CASCADE',
|
||||
'delete_rule' => 'CASCADE',
|
||||
],
|
||||
'target' => [
|
||||
'columns' => ['parent_id'],
|
||||
'referenced_table' => 'posts',
|
||||
'referenced_columns' => ['id'],
|
||||
'update_rule' => 'CASCADE',
|
||||
'delete_rule' => 'SET NULL',
|
||||
],
|
||||
],
|
||||
] // modified foreign keys
|
||||
);
|
||||
|
||||
expect($tableDifference->tableName)->toBe('posts');
|
||||
expect($tableDifference->missingColumns)->toBeEmpty();
|
||||
expect($tableDifference->extraColumns)->toBeEmpty();
|
||||
expect($tableDifference->modifiedColumns)->toBeEmpty();
|
||||
expect($tableDifference->missingIndexes)->toBeEmpty();
|
||||
expect($tableDifference->extraIndexes)->toBeEmpty();
|
||||
expect($tableDifference->modifiedIndexes)->toBeEmpty();
|
||||
expect($tableDifference->missingForeignKeys)->toHaveCount(1);
|
||||
expect($tableDifference->extraForeignKeys)->toHaveCount(1);
|
||||
expect($tableDifference->modifiedForeignKeys)->toHaveCount(1);
|
||||
expect($tableDifference->hasDifferences())->toBeTrue();
|
||||
});
|
||||
|
||||
test('creates a table difference with no differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
[], // missing columns
|
||||
[], // extra columns
|
||||
[], // modified columns
|
||||
[], // missing indexes
|
||||
[], // extra indexes
|
||||
[], // modified indexes
|
||||
[], // missing foreign keys
|
||||
[], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
expect($tableDifference->tableName)->toBe('users');
|
||||
expect($tableDifference->missingColumns)->toBeEmpty();
|
||||
expect($tableDifference->extraColumns)->toBeEmpty();
|
||||
expect($tableDifference->modifiedColumns)->toBeEmpty();
|
||||
expect($tableDifference->missingIndexes)->toBeEmpty();
|
||||
expect($tableDifference->extraIndexes)->toBeEmpty();
|
||||
expect($tableDifference->modifiedIndexes)->toBeEmpty();
|
||||
expect($tableDifference->missingForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->extraForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->modifiedForeignKeys)->toBeEmpty();
|
||||
expect($tableDifference->hasDifferences())->toBeFalse();
|
||||
});
|
||||
|
||||
test('gets summary of differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
['email' => ['type' => 'varchar', 'nullable' => false]], // missing columns
|
||||
['username' => ['type' => 'varchar', 'nullable' => false]], // extra columns
|
||||
[
|
||||
'name' => [
|
||||
'source' => ['type' => 'varchar', 'nullable' => false],
|
||||
'target' => ['type' => 'varchar', 'nullable' => true],
|
||||
],
|
||||
], // modified columns
|
||||
['idx_users_email' => ['type' => 'unique', 'columns' => ['email']]], // missing indexes
|
||||
['idx_users_username' => ['type' => 'unique', 'columns' => ['username']]], // extra indexes
|
||||
[], // modified indexes
|
||||
['fk_users_role_id' => []], // missing foreign keys
|
||||
['fk_users_team_id' => []], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
$summary = $tableDifference->getSummary();
|
||||
|
||||
expect($summary)->toBeArray();
|
||||
expect($summary['missing_columns'])->toBe(1);
|
||||
expect($summary['extra_columns'])->toBe(1);
|
||||
expect($summary['modified_columns'])->toBe(1);
|
||||
expect($summary['missing_indexes'])->toBe(1);
|
||||
expect($summary['extra_indexes'])->toBe(1);
|
||||
expect($summary['modified_indexes'])->toBe(0);
|
||||
expect($summary['missing_foreign_keys'])->toBe(1);
|
||||
expect($summary['extra_foreign_keys'])->toBe(1);
|
||||
expect($summary['modified_foreign_keys'])->toBe(0);
|
||||
});
|
||||
|
||||
test('gets description of differences', function () {
|
||||
$tableDifference = new TableDifference(
|
||||
'users',
|
||||
['email' => ['type' => 'varchar', 'nullable' => false]], // missing columns
|
||||
['username' => ['type' => 'varchar', 'nullable' => false]], // extra columns
|
||||
[
|
||||
'name' => [
|
||||
'source' => ['type' => 'varchar', 'nullable' => false],
|
||||
'target' => ['type' => 'varchar', 'nullable' => true],
|
||||
],
|
||||
], // modified columns
|
||||
['idx_users_email' => ['type' => 'unique', 'columns' => ['email']]], // missing indexes
|
||||
['idx_users_username' => ['type' => 'unique', 'columns' => ['username']]], // extra indexes
|
||||
[], // modified indexes
|
||||
['fk_users_role_id' => [
|
||||
'columns' => ['role_id'],
|
||||
'referenced_table' => 'roles',
|
||||
'referenced_columns' => ['id'],
|
||||
]], // missing foreign keys
|
||||
['fk_users_team_id' => [
|
||||
'columns' => ['team_id'],
|
||||
'referenced_table' => 'teams',
|
||||
'referenced_columns' => ['id'],
|
||||
]], // extra foreign keys
|
||||
[] // modified foreign keys
|
||||
);
|
||||
|
||||
$description = $tableDifference->getDescription();
|
||||
|
||||
expect($description)->toBeString();
|
||||
expect($description)->toContain('Table: users');
|
||||
expect($description)->toContain('Missing Columns');
|
||||
expect($description)->toContain('email');
|
||||
expect($description)->toContain('Extra Columns');
|
||||
expect($description)->toContain('username');
|
||||
expect($description)->toContain('Modified Columns');
|
||||
expect($description)->toContain('name');
|
||||
expect($description)->toContain('Missing Indexes');
|
||||
expect($description)->toContain('idx_users_email');
|
||||
expect($description)->toContain('Extra Indexes');
|
||||
expect($description)->toContain('idx_users_username');
|
||||
expect($description)->toContain('Missing Foreign Keys');
|
||||
expect($description)->toContain('fk_users_role_id');
|
||||
expect($description)->toContain('Extra Foreign Keys');
|
||||
expect($description)->toContain('fk_users_team_id');
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\Schema\Index;
|
||||
|
||||
use App\Framework\Database\Schema\Index\AdvancedIndexDefinition;
|
||||
use App\Framework\Database\Schema\Index\AdvancedIndexType;
|
||||
|
||||
test('creates a standard index definition', function () {
|
||||
$index = AdvancedIndexDefinition::create(
|
||||
'idx_users_email',
|
||||
['email'],
|
||||
AdvancedIndexType::INDEX
|
||||
);
|
||||
|
||||
expect($index->name)->toBe('idx_users_email');
|
||||
expect($index->columns)->toBe(['email']);
|
||||
expect($index->type)->toBe(AdvancedIndexType::INDEX);
|
||||
expect($index->whereClause)->toBeNull();
|
||||
expect($index->options)->toBeEmpty();
|
||||
expect($index->isFunctional)->toBeFalse();
|
||||
});
|
||||
|
||||
test('creates a partial index definition', function () {
|
||||
$index = AdvancedIndexDefinition::partial(
|
||||
'idx_users_email',
|
||||
['email'],
|
||||
AdvancedIndexType::INDEX,
|
||||
'active = true'
|
||||
);
|
||||
|
||||
expect($index->name)->toBe('idx_users_email');
|
||||
expect($index->columns)->toBe(['email']);
|
||||
expect($index->type)->toBe(AdvancedIndexType::INDEX);
|
||||
expect($index->whereClause)->toBe('active = true');
|
||||
expect($index->options)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('creates a functional index definition', function () {
|
||||
$index = AdvancedIndexDefinition::functional(
|
||||
'idx_users_lower_email',
|
||||
AdvancedIndexType::INDEX,
|
||||
['LOWER(email)']
|
||||
);
|
||||
|
||||
expect($index->name)->toBe('idx_users_lower_email');
|
||||
expect($index->columns)->toBeEmpty();
|
||||
expect($index->type)->toBe(AdvancedIndexType::INDEX);
|
||||
expect($index->isFunctional)->toBeTrue();
|
||||
expect($index->expressions)->toBe(['LOWER(email)']);
|
||||
});
|
||||
|
||||
test('creates a GIN index definition', function () {
|
||||
$index = AdvancedIndexDefinition::gin(
|
||||
'idx_documents_content',
|
||||
['content']
|
||||
);
|
||||
|
||||
expect($index->name)->toBe('idx_documents_content');
|
||||
expect($index->columns)->toBe(['content']);
|
||||
expect($index->type)->toBe(AdvancedIndexType::GIN);
|
||||
});
|
||||
|
||||
test('creates a GiST index definition', function () {
|
||||
$index = AdvancedIndexDefinition::gist(
|
||||
'idx_locations_position',
|
||||
['position']
|
||||
);
|
||||
|
||||
expect($index->name)->toBe('idx_locations_position');
|
||||
expect($index->columns)->toBe(['position']);
|
||||
expect($index->type)->toBe(AdvancedIndexType::GIST);
|
||||
});
|
||||
|
||||
test('creates a BTREE index definition with options', function () {
|
||||
$index = AdvancedIndexDefinition::btree(
|
||||
'idx_users_name',
|
||||
['first_name', 'last_name'],
|
||||
['fillfactor' => 70]
|
||||
);
|
||||
|
||||
expect($index->name)->toBe('idx_users_name');
|
||||
expect($index->columns)->toBe(['first_name', 'last_name']);
|
||||
expect($index->type)->toBe(AdvancedIndexType::BTREE);
|
||||
expect($index->options)->toBe(['fillfactor' => 70]);
|
||||
});
|
||||
|
||||
test('generates PostgreSQL partial index SQL', function () {
|
||||
$index = AdvancedIndexDefinition::partial(
|
||||
'idx_users_email',
|
||||
['email'],
|
||||
AdvancedIndexType::INDEX,
|
||||
'active = true'
|
||||
);
|
||||
|
||||
$sql = $index->toSql('pgsql', 'users');
|
||||
|
||||
expect($sql)->toBe('CREATE INDEX "idx_users_email" ON "users" ("email") WHERE active = true');
|
||||
});
|
||||
|
||||
test('generates PostgreSQL functional index SQL', function () {
|
||||
$index = AdvancedIndexDefinition::functional(
|
||||
'idx_users_lower_email',
|
||||
AdvancedIndexType::INDEX,
|
||||
['LOWER(email)']
|
||||
);
|
||||
|
||||
$sql = $index->toSql('pgsql', 'users');
|
||||
|
||||
expect($sql)->toBe('CREATE INDEX "idx_users_lower_email" ON "users" (LOWER(email))');
|
||||
});
|
||||
|
||||
test('generates PostgreSQL GIN index SQL', function () {
|
||||
$index = AdvancedIndexDefinition::gin(
|
||||
'idx_documents_content',
|
||||
['content']
|
||||
);
|
||||
|
||||
$sql = $index->toSql('pgsql', 'documents');
|
||||
|
||||
expect($sql)->toBe('CREATE INDEX "idx_documents_content" ON "documents" USING gin ("content")');
|
||||
});
|
||||
|
||||
test('generates PostgreSQL GiST index SQL', function () {
|
||||
$index = AdvancedIndexDefinition::gist(
|
||||
'idx_locations_position',
|
||||
['position']
|
||||
);
|
||||
|
||||
$sql = $index->toSql('pgsql', 'locations');
|
||||
|
||||
expect($sql)->toBe('CREATE INDEX "idx_locations_position" ON "locations" USING gist ("position")');
|
||||
});
|
||||
|
||||
test('generates PostgreSQL index SQL with options', function () {
|
||||
$index = AdvancedIndexDefinition::btree(
|
||||
'idx_users_email',
|
||||
['email'],
|
||||
['fillfactor' => 70]
|
||||
);
|
||||
|
||||
$sql = $index->toSql('pgsql', 'users');
|
||||
|
||||
// Note: PostgreSQL doesn't use USING btree by default, so it's not in the SQL
|
||||
expect($sql)->toBe('CREATE INDEX "idx_users_email" ON "users" ("email")');
|
||||
});
|
||||
|
||||
test('throws exception for partial index in MySQL', function () {
|
||||
$index = AdvancedIndexDefinition::partial(
|
||||
'idx_users_email',
|
||||
['email'],
|
||||
AdvancedIndexType::INDEX,
|
||||
'active = true'
|
||||
);
|
||||
|
||||
expect(fn () => $index->toSql('mysql', 'users'))
|
||||
->toThrow(\InvalidArgumentException::class, 'MySQL does not support partial indexes');
|
||||
});
|
||||
|
||||
test('generates MySQL functional index SQL', function () {
|
||||
$index = AdvancedIndexDefinition::functional(
|
||||
'idx_users_lower_email',
|
||||
AdvancedIndexType::INDEX,
|
||||
['LOWER(email)']
|
||||
);
|
||||
|
||||
$sql = $index->toSql('mysql', 'users');
|
||||
|
||||
// MySQL 8.0+ supports functional indexes with ALTER TABLE
|
||||
expect($sql)->toBe('ALTER TABLE `users` ADD INDEX `idx_users_lower_email` (LOWER(email))');
|
||||
});
|
||||
|
||||
test('throws exception for unsupported index type in MySQL', function () {
|
||||
$index = AdvancedIndexDefinition::gin(
|
||||
'idx_documents_content',
|
||||
['content']
|
||||
);
|
||||
|
||||
expect(fn () => $index->toSql('mysql', 'documents'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Index type gin is not supported by mysql');
|
||||
});
|
||||
|
||||
test('throws exception for unsupported database driver', function () {
|
||||
$index = AdvancedIndexDefinition::btree(
|
||||
'idx_users_email',
|
||||
['email']
|
||||
);
|
||||
|
||||
expect(fn () => $index->toSql('unsupported_driver', 'users'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Unsupported driver: unsupported_driver');
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\StoredProcedure;
|
||||
|
||||
use App\Framework\Database\StoredProcedure\StoredProcedureDefinition;
|
||||
|
||||
test('creates a stored procedure definition with builder pattern', function () {
|
||||
$procedure = StoredProcedureDefinition::create('get_user_by_id')
|
||||
->withParameter('user_id', 'INT')
|
||||
->withBody('SELECT * FROM users WHERE id = user_id')
|
||||
->returnsType('TABLE');
|
||||
|
||||
expect($procedure->getName())->toBe('get_user_by_id');
|
||||
expect($procedure->getParameters())->toHaveCount(1);
|
||||
expect($procedure->getParameters()[0]['name'])->toBe('user_id');
|
||||
expect($procedure->getParameters()[0]['type'])->toBe('INT');
|
||||
expect($procedure->getBody())->toBe('SELECT * FROM users WHERE id = user_id');
|
||||
expect($procedure->getReturnType())->toBe('TABLE');
|
||||
});
|
||||
|
||||
test('generates MySQL stored procedure SQL', function () {
|
||||
$procedure = StoredProcedureDefinition::create('calculate_order_total')
|
||||
->withParameter('order_id', 'INT')
|
||||
->withParameter('include_tax', 'BOOLEAN', true)
|
||||
->withBody('
|
||||
DECLARE total DECIMAL(10,2);
|
||||
SELECT SUM(price * quantity) INTO total FROM order_items WHERE order_id = order_id;
|
||||
IF include_tax THEN
|
||||
SET total = total * 1.19;
|
||||
END IF;
|
||||
RETURN total;
|
||||
')
|
||||
->returnsType('DECIMAL(10,2)');
|
||||
|
||||
$sql = $procedure->toSql('mysql');
|
||||
|
||||
expect($sql)->toContain('CREATE PROCEDURE `calculate_order_total`');
|
||||
expect($sql)->toContain('IN `order_id` INT');
|
||||
expect($sql)->toContain('IN `include_tax` BOOLEAN');
|
||||
expect($sql)->toContain('DECLARE total DECIMAL(10,2)');
|
||||
expect($sql)->toContain('DELIMITER');
|
||||
});
|
||||
|
||||
test('generates PostgreSQL stored procedure SQL', function () {
|
||||
$procedure = StoredProcedureDefinition::create('get_active_users')
|
||||
->withParameter('min_login_count', 'INT')
|
||||
->withBody('
|
||||
RETURN QUERY
|
||||
SELECT * FROM users
|
||||
WHERE active = true AND login_count >= min_login_count
|
||||
ORDER BY last_login DESC;
|
||||
')
|
||||
->returnsType('SETOF users');
|
||||
|
||||
$sql = $procedure->toSql('pgsql');
|
||||
|
||||
expect($sql)->toContain('CREATE OR REPLACE FUNCTION get_active_users');
|
||||
expect($sql)->toContain('min_login_count INT');
|
||||
expect($sql)->toContain('RETURNS SETOF users');
|
||||
expect($sql)->toContain('LANGUAGE plpgsql');
|
||||
});
|
||||
|
||||
test('generates SQLite stored procedure SQL as user-defined function', function () {
|
||||
$procedure = StoredProcedureDefinition::create('calculate_age')
|
||||
->withParameter('birth_date', 'TEXT')
|
||||
->withBody('
|
||||
RETURN (julianday("now") - julianday(birth_date)) / 365.25;
|
||||
')
|
||||
->returnsType('REAL');
|
||||
|
||||
$sql = $procedure->toSql('sqlite');
|
||||
|
||||
expect($sql)->toContain('CREATE FUNCTION calculate_age');
|
||||
expect($sql)->toContain('(birth_date TEXT)');
|
||||
expect($sql)->toContain('RETURNS REAL');
|
||||
});
|
||||
|
||||
test('throws exception for unsupported database driver', function () {
|
||||
$procedure = StoredProcedureDefinition::create('test_procedure')
|
||||
->withBody('SELECT 1')
|
||||
->returnsType('INT');
|
||||
|
||||
expect(fn () => $procedure->toSql('unsupported_driver'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Unsupported database driver: unsupported_driver');
|
||||
});
|
||||
|
||||
test('validates procedure name', function () {
|
||||
expect(fn () => StoredProcedureDefinition::create(''))
|
||||
->toThrow(\InvalidArgumentException::class, 'Procedure name cannot be empty');
|
||||
|
||||
expect(fn () => StoredProcedureDefinition::create('invalid-name'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Invalid procedure name: invalid-name');
|
||||
});
|
||||
|
||||
test('validates parameter name', function () {
|
||||
$procedure = StoredProcedureDefinition::create('test_procedure');
|
||||
|
||||
expect(fn () => $procedure->withParameter('', 'INT'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Parameter name cannot be empty');
|
||||
|
||||
expect(fn () => $procedure->withParameter('invalid-name', 'INT'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Invalid parameter name: invalid-name');
|
||||
});
|
||||
|
||||
test('validates body is not empty before generating SQL', function () {
|
||||
$procedure = StoredProcedureDefinition::create('test_procedure');
|
||||
|
||||
expect(fn () => $procedure->toSql('mysql'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Procedure body cannot be empty');
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Database\StoredProcedure;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\ResultInterface;
|
||||
use App\Framework\Database\StoredProcedure\StoredProcedureDefinition;
|
||||
use App\Framework\Database\StoredProcedure\StoredProcedureManager;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->connection = Mockery::mock(ConnectionInterface::class);
|
||||
$this->manager = new StoredProcedureManager($this->connection);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
test('creates a stored procedure', function () {
|
||||
$procedure = StoredProcedureDefinition::create('get_user_by_id')
|
||||
->withParameter('user_id', 'INT')
|
||||
->withBody('SELECT * FROM users WHERE id = user_id')
|
||||
->returnsType('TABLE');
|
||||
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('mysql');
|
||||
|
||||
$this->connection->shouldReceive('execute')
|
||||
->with(Mockery::pattern('/CREATE PROCEDURE/'), [])
|
||||
->once()
|
||||
->andReturn(1);
|
||||
|
||||
$result = $this->manager->createProcedure($procedure);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
test('drops a stored procedure', function () {
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('mysql');
|
||||
|
||||
$this->connection->shouldReceive('execute')
|
||||
->with('DROP PROCEDURE IF EXISTS `get_user_by_id`', [])
|
||||
->once()
|
||||
->andReturn(1);
|
||||
|
||||
$result = $this->manager->dropProcedure('get_user_by_id');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
test('checks if a stored procedure exists', function () {
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('mysql');
|
||||
|
||||
$this->connection->shouldReceive('queryScalar')
|
||||
->with(Mockery::pattern('/INFORMATION_SCHEMA.ROUTINES/'), ['get_user_by_id'])
|
||||
->once()
|
||||
->andReturn(1);
|
||||
|
||||
$result = $this->manager->procedureExists('get_user_by_id');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
test('executes a stored procedure with parameters', function () {
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('mysql');
|
||||
|
||||
$mockResult = Mockery::mock(ResultInterface::class);
|
||||
$mockResult->shouldReceive('fetchAll')->andReturn([
|
||||
['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'],
|
||||
]);
|
||||
|
||||
$this->connection->shouldReceive('query')
|
||||
->with('CALL get_user_by_id(?)', [42])
|
||||
->once()
|
||||
->andReturn($mockResult);
|
||||
|
||||
$result = $this->manager->executeProcedure('get_user_by_id', [42]);
|
||||
|
||||
expect($result)->toBeInstanceOf(ResultInterface::class);
|
||||
expect($result->fetchAll())->toHaveCount(1);
|
||||
expect($result->fetchAll()[0]['name'])->toBe('John Doe');
|
||||
});
|
||||
|
||||
test('executes a stored function with parameters', function () {
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('mysql');
|
||||
|
||||
$this->connection->shouldReceive('queryScalar')
|
||||
->with('SELECT calculate_order_total(?, ?)', [123, true])
|
||||
->once()
|
||||
->andReturn(99.99);
|
||||
|
||||
$result = $this->manager->executeFunction('calculate_order_total', [123, true]);
|
||||
|
||||
expect($result)->toBe(99.99);
|
||||
});
|
||||
|
||||
test('lists all stored procedures', function () {
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('mysql');
|
||||
|
||||
$mockResult = Mockery::mock(ResultInterface::class);
|
||||
$mockResult->shouldReceive('fetchAll')->andReturn([
|
||||
['ROUTINE_NAME' => 'get_user_by_id', 'ROUTINE_TYPE' => 'PROCEDURE'],
|
||||
['ROUTINE_NAME' => 'calculate_order_total', 'ROUTINE_TYPE' => 'FUNCTION'],
|
||||
]);
|
||||
|
||||
$this->connection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/INFORMATION_SCHEMA.ROUTINES/'), [])
|
||||
->once()
|
||||
->andReturn($mockResult);
|
||||
|
||||
$procedures = $this->manager->listProcedures();
|
||||
|
||||
expect($procedures)->toHaveCount(2);
|
||||
expect($procedures[0]['name'])->toBe('get_user_by_id');
|
||||
expect($procedures[0]['type'])->toBe('PROCEDURE');
|
||||
expect($procedures[1]['name'])->toBe('calculate_order_total');
|
||||
expect($procedures[1]['type'])->toBe('FUNCTION');
|
||||
});
|
||||
|
||||
test('gets procedure definition', function () {
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('mysql');
|
||||
|
||||
$mockResult = Mockery::mock(ResultInterface::class);
|
||||
$mockResult->shouldReceive('fetchOne')->andReturn([
|
||||
'ROUTINE_NAME' => 'get_user_by_id',
|
||||
'ROUTINE_TYPE' => 'PROCEDURE',
|
||||
'ROUTINE_DEFINITION' => 'SELECT * FROM users WHERE id = user_id',
|
||||
'DTD_IDENTIFIER' => null,
|
||||
'PARAMETER_STYLE' => 'SQL',
|
||||
]);
|
||||
|
||||
$this->connection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/INFORMATION_SCHEMA.ROUTINES/'), ['get_user_by_id'])
|
||||
->once()
|
||||
->andReturn($mockResult);
|
||||
|
||||
$paramResult = Mockery::mock(ResultInterface::class);
|
||||
$paramResult->shouldReceive('fetchAll')->andReturn([
|
||||
['PARAMETER_NAME' => 'user_id', 'DATA_TYPE' => 'INT', 'PARAMETER_MODE' => 'IN'],
|
||||
]);
|
||||
|
||||
$this->connection->shouldReceive('query')
|
||||
->with(Mockery::pattern('/INFORMATION_SCHEMA.PARAMETERS/'), ['get_user_by_id'])
|
||||
->once()
|
||||
->andReturn($paramResult);
|
||||
|
||||
$definition = $this->manager->getProcedureDefinition('get_user_by_id');
|
||||
|
||||
expect($definition)->toBeInstanceOf(StoredProcedureDefinition::class);
|
||||
expect($definition->getName())->toBe('get_user_by_id');
|
||||
expect($definition->getBody())->toBe('SELECT * FROM users WHERE id = user_id');
|
||||
expect($definition->getParameters())->toHaveCount(1);
|
||||
expect($definition->getParameters()[0]['name'])->toBe('user_id');
|
||||
expect($definition->getParameters()[0]['type'])->toBe('INT');
|
||||
});
|
||||
|
||||
test('handles unsupported database driver', function () {
|
||||
$this->connection->shouldReceive('getPdo->getAttribute')
|
||||
->with(\PDO::ATTR_DRIVER_NAME)
|
||||
->andReturn('unsupported_driver');
|
||||
|
||||
expect(fn () => $this->manager->procedureExists('test_procedure'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Unsupported database driver: unsupported_driver');
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Domain\Common\ValueObject\PhoneNumber;
|
||||
use App\Framework\Database\TypeCaster\PhoneNumberCaster;
|
||||
|
||||
describe('PhoneNumberCaster', function () {
|
||||
beforeEach(function () {
|
||||
$this->caster = new PhoneNumberCaster();
|
||||
});
|
||||
|
||||
it('supports PhoneNumber type', function () {
|
||||
expect($this->caster->supports(PhoneNumber::class))->toBeTrue();
|
||||
expect($this->caster->supports('string'))->toBeFalse();
|
||||
expect($this->caster->supports('SomeOtherClass'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('casts from database string to PhoneNumber', function () {
|
||||
$phoneString = '+49 123 456789';
|
||||
$result = $this->caster->fromDatabase($phoneString);
|
||||
|
||||
expect($result)->toBeInstanceOf(PhoneNumber::class);
|
||||
expect($result->getValue())->toBe($phoneString);
|
||||
});
|
||||
|
||||
it('casts null from database to null', function () {
|
||||
$result = $this->caster->fromDatabase(null);
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('casts empty string from database to null', function () {
|
||||
$result = $this->caster->fromDatabase('');
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('throws exception for non-string database value', function () {
|
||||
expect(fn () => $this->caster->fromDatabase(123))
|
||||
->toThrow(InvalidArgumentException::class, 'Expected string for PhoneNumber, got integer');
|
||||
|
||||
expect(fn () => $this->caster->fromDatabase([]))
|
||||
->toThrow(InvalidArgumentException::class, 'Expected string for PhoneNumber, got array');
|
||||
});
|
||||
|
||||
it('casts PhoneNumber to database string', function () {
|
||||
$phone = PhoneNumber::from('+49 123 456789');
|
||||
$result = $this->caster->toDatabase($phone);
|
||||
|
||||
expect($result)->toBe('+49 123 456789');
|
||||
});
|
||||
|
||||
it('casts null PhoneNumber to null', function () {
|
||||
$result = $this->caster->toDatabase(null);
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('throws exception for non-PhoneNumber value', function () {
|
||||
expect(fn () => $this->caster->toDatabase('string'))
|
||||
->toThrow(InvalidArgumentException::class, 'Expected PhoneNumber instance, got string');
|
||||
|
||||
expect(fn () => $this->caster->toDatabase(123))
|
||||
->toThrow(InvalidArgumentException::class, 'Expected PhoneNumber instance, got integer');
|
||||
});
|
||||
|
||||
it('handles round-trip conversion correctly', function () {
|
||||
$originalPhone = PhoneNumber::from('+49 151 12345678');
|
||||
|
||||
// To database
|
||||
$dbValue = $this->caster->toDatabase($originalPhone);
|
||||
expect($dbValue)->toBe('+49 151 12345678');
|
||||
|
||||
// From database
|
||||
$restoredPhone = $this->caster->fromDatabase($dbValue);
|
||||
expect($restoredPhone)->toBeInstanceOf(PhoneNumber::class);
|
||||
expect($restoredPhone->getValue())->toBe($originalPhone->getValue());
|
||||
expect($restoredPhone->equals($originalPhone))->toBeTrue();
|
||||
});
|
||||
|
||||
it('preserves phone number formatting through database', function () {
|
||||
$testNumbers = [
|
||||
'+49 123 456789',
|
||||
'+1 (555) 123-4567',
|
||||
'0123 456789',
|
||||
'+33 1 23 45 67 89',
|
||||
];
|
||||
|
||||
foreach ($testNumbers as $number) {
|
||||
$phone = PhoneNumber::from($number);
|
||||
$dbValue = $this->caster->toDatabase($phone);
|
||||
$restoredPhone = $this->caster->fromDatabase($dbValue);
|
||||
|
||||
expect($restoredPhone->getValue())->toBe($number);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user