From 43ad1c4f61704ebb1ef4c1bfaed7d980a4dc33d8 Mon Sep 17 00:00:00 2001 From: Thanos Talaridis Date: Thu, 20 Aug 2026 17:12:01 +0300 Subject: [PATCH] fix(tia): select only the tests that touch a changed migration's tables --- src/Plugins/Tia.php | 60 +------------ src/Plugins/Tia/DatabaseTestTables.php | 86 +++++++++++++++++++ src/Plugins/Tia/Graph.php | 54 ++++++------ tests/Unit/Plugins/Tia/DatabaseTestTables.php | 77 +++++++++++++++++ tests/Unit/Plugins/Tia/Graph.php | 26 ++++++ 5 files changed, 218 insertions(+), 85 deletions(-) create mode 100644 src/Plugins/Tia/DatabaseTestTables.php create mode 100644 tests/Unit/Plugins/Tia/DatabaseTestTables.php diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index ff1f5b066..737ba2687 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -23,6 +23,7 @@ use Pest\Plugins\Tia\CiDefaultBranch; use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\CoverageCollector; +use Pest\Plugins\Tia\DatabaseTestTables; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\JsModuleGraph; @@ -30,7 +31,6 @@ use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\SourceScope; use Pest\Plugins\Tia\Storage; -use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\WatchPatterns; use Pest\Support\Container; use Pest\Support\Git; @@ -604,11 +604,7 @@ public function terminate(): void $perTestUsesDatabase = $recorder->perTestUsesDatabase(); if ($perTestUsesDatabase !== []) { - $perTestTables = $this->augmentDatabaseTestTables( - $perTestTables, - $perTestUsesDatabase, - $projectRoot, - ); + $perTestTables = DatabaseTestTables::augment($perTestTables, $perTestUsesDatabase, $projectRoot); } if (Parallel::isWorker()) { @@ -2209,58 +2205,6 @@ private function composerLockDelta(string $projectRoot, string $sha): string return implode(', ', $changes); } - /** - * @param array> $perTestTables - * @param array $perTestUsesDatabase - * @return array> - */ - private function augmentDatabaseTestTables(array $perTestTables, array $perTestUsesDatabase, string $projectRoot): array - { - $migrationDir = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations'; - - if (! is_dir($migrationDir)) { - return $perTestTables; - } - - $allTables = []; - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($migrationDir, \FilesystemIterator::SKIP_DOTS), - ); - - foreach ($iterator as $fileInfo) { - if (! $fileInfo->isFile()) { - continue; - } - if (! str_ends_with(strtolower((string) $fileInfo->getPathname()), '.php')) { - continue; - } - - $content = @file_get_contents((string) $fileInfo->getPathname()); - - if ($content === false) { - continue; - } - - foreach (TableExtractor::fromMigrationSource($content) as $table) { - $allTables[strtolower($table)] = true; - } - } - - if ($allTables === []) { - return $perTestTables; - } - - foreach (array_keys($perTestUsesDatabase) as $testFile) { - $existing = $perTestTables[$testFile] ?? []; - $merged = array_fill_keys($existing, true) + $allTables; - $names = array_keys($merged); - sort($names); - $perTestTables[$testFile] = $names; - } - - return $perTestTables; - } - /** * @return array package name → version */ diff --git a/src/Plugins/Tia/DatabaseTestTables.php b/src/Plugins/Tia/DatabaseTestTables.php new file mode 100644 index 000000000..2ff461b0c --- /dev/null +++ b/src/Plugins/Tia/DatabaseTestTables.php @@ -0,0 +1,86 @@ +> $perTestTables + * @param array $perTestUsesDatabase + * @return array> + */ + public static function augment(array $perTestTables, array $perTestUsesDatabase, string $projectRoot): array + { + $untracked = array_filter( + array_keys($perTestUsesDatabase), + fn (string $testFile): bool => ($perTestTables[$testFile] ?? []) === [], + ); + + if ($untracked === []) { + return $perTestTables; + } + + $allTables = self::declaredTables($projectRoot); + + if ($allTables === []) { + return $perTestTables; + } + + foreach ($untracked as $testFile) { + $perTestTables[$testFile] = $allTables; + } + + return $perTestTables; + } + + /** + * @return array + */ + private static function declaredTables(string $projectRoot): array + { + $migrationDir = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations'; + + if (! is_dir($migrationDir)) { + return []; + } + + $tables = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($migrationDir, FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $fileInfo) { + if (! $fileInfo->isFile()) { + continue; + } + + if (! str_ends_with(strtolower((string) $fileInfo->getPathname()), '.php')) { + continue; + } + + $content = @file_get_contents((string) $fileInfo->getPathname()); + + if ($content === false) { + continue; + } + + foreach (TableExtractor::fromMigrationSource($content) as $table) { + $tables[strtolower($table)] = true; + } + } + + $names = array_keys($tables); + sort($names); + + return $names; + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 4f0d77f05..dbbf6bda1 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -103,7 +103,7 @@ public function affected(array $changedFiles): array $affectedSet = []; - $unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet); + $unmatchedMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet); [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved] = $this->applyInertiaChanges($nonMigrationPaths, $affectedSet); @@ -117,7 +117,7 @@ public function affected(array $changedFiles): array $this->applyWatchPatternFallback( $nonMigrationPaths, - $unparseableMigrations, + $unmatchedMigrations, $preciselyHandledPages, $sharedFilesResolved, $handledBlade, @@ -176,7 +176,7 @@ private function partitionChangedPaths(array $changedFiles): array /** * @param list $migrationPaths * @param array $affectedSet - * @return list Unparseable migrations (caller treats as unknown-to-graph). + * @return list Migrations that could not be matched by table (caller treats as unknown-to-graph). */ private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array { @@ -184,40 +184,40 @@ private function applyMigrationChanges(array $migrationPaths, array &$affectedSe return $migrationPaths; } - $changedTables = []; - $unparseable = []; + $unmatched = []; foreach ($migrationPaths as $rel) { $tables = $this->tablesForMigration($rel); - if ($tables === []) { - $unparseable[] = $rel; - - continue; - } - - foreach ($tables as $table) { - $changedTables[$table] = true; + if ($tables === [] || ! $this->selectTestsUsingTables($tables, $affectedSet)) { + $unmatched[] = $rel; } } - if ($changedTables !== []) { - foreach ($this->testTables as $testFile => $tables) { - if (isset($affectedSet[$testFile])) { - continue; - } + return $unmatched; + } - foreach ($tables as $table) { - if (isset($changedTables[$table])) { - $affectedSet[$testFile] = true; + /** + * @param list $changedTables + * @param array $affectedSet + */ + private function selectTestsUsingTables(array $changedTables, array &$affectedSet): bool + { + $changed = array_fill_keys($changedTables, true); + $selected = false; - break; - } + foreach ($this->testTables as $testFile => $tables) { + foreach ($tables as $table) { + if (isset($changed[$table])) { + $affectedSet[$testFile] = true; + $selected = true; + + break; } } } - return $unparseable; + return $selected; } /** @@ -624,7 +624,7 @@ private function livewireGeneratedHash(string $path): ?string /** * @param list $nonMigrationPaths - * @param list $unparseableMigrations + * @param list $unmatchedMigrations * @param array $preciselyHandledPages * @param array $sharedFilesResolved * @param array $handledBlade @@ -632,13 +632,13 @@ private function livewireGeneratedHash(string $path): ?string */ private function applyWatchPatternFallback( array $nonMigrationPaths, - array $unparseableMigrations, + array $unmatchedMigrations, array $preciselyHandledPages, array $sharedFilesResolved, array $handledBlade, array &$affectedSet, ): void { - $unknownToGraph = $unparseableMigrations; + $unknownToGraph = $unmatchedMigrations; foreach ($nonMigrationPaths as $rel) { if (isset($preciselyHandledPages[$rel])) { diff --git a/tests/Unit/Plugins/Tia/DatabaseTestTables.php b/tests/Unit/Plugins/Tia/DatabaseTestTables.php new file mode 100644 index 000000000..77c5ca14b --- /dev/null +++ b/tests/Unit/Plugins/Tia/DatabaseTestTables.php @@ -0,0 +1,77 @@ +projectRoot = sys_get_temp_dir().'/pest-tia-db-tables-'.bin2hex(random_bytes(4)); + mkdir($this->projectRoot.'/database/migrations', 0755, true); + file_put_contents( + $this->projectRoot.'/database/migrations/2024_01_01_000000_create_orders_table.php', + "projectRoot.'/database/migrations/2024_01_02_000000_create_users_table.php', + "projectRoot.'/database/migrations/*.php') ?: [] as $migration) { + unlink($migration); + } + + @rmdir($this->projectRoot.'/database/migrations'); + @rmdir($this->projectRoot.'/database'); + @rmdir($this->projectRoot); + }); + + it('keeps the recorded tables of a database test that tracked its queries', function (): void { + $augmented = DatabaseTestTables::augment( + ['tests/Feature/OrderTest.php' => ['orders']], + ['tests/Feature/OrderTest.php' => true], + $this->projectRoot, + ); + + expect($augmented)->toBe(['tests/Feature/OrderTest.php' => ['orders']]); + }); + + it('links a database test with no recorded tables to every declared table', function (): void { + $augmented = DatabaseTestTables::augment( + ['tests/Feature/OrderTest.php' => ['orders']], + ['tests/Feature/OrderTest.php' => true, 'tests/Feature/BootTest.php' => true], + $this->projectRoot, + ); + + expect($augmented)->toBe([ + 'tests/Feature/OrderTest.php' => ['orders'], + 'tests/Feature/BootTest.php' => ['orders', 'users'], + ]); + }); + + it('leaves tests that do not use the database untouched', function (): void { + $augmented = DatabaseTestTables::augment( + ['tests/Unit/MathTest.php' => []], + [], + $this->projectRoot, + ); + + expect($augmented)->toBe(['tests/Unit/MathTest.php' => []]); + }); + + it('does nothing when the migrations declare no tables', function (): void { + foreach (glob($this->projectRoot.'/database/migrations/*.php') ?: [] as $migration) { + unlink($migration); + } + + $augmented = DatabaseTestTables::augment( + [], + ['tests/Feature/BootTest.php' => true], + $this->projectRoot, + ); + + expect($augmented)->toBeEmpty(); + }); +}); diff --git a/tests/Unit/Plugins/Tia/Graph.php b/tests/Unit/Plugins/Tia/Graph.php index 19ea5ca00..ad33dbd80 100644 --- a/tests/Unit/Plugins/Tia/Graph.php +++ b/tests/Unit/Plugins/Tia/Graph.php @@ -53,6 +53,32 @@ expect($affected)->toBe(['tests/Feature/OrderTest.php']); }); + it('falls back to watch patterns when no recorded test touches the changed table', function (): void { + $this->watchPatterns->add(['database/migrations/**' => 'tests/Feature']); + + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/UserTest.php', 'app/Models/User.php'); + $graph->replaceTestTables([ + 'tests/Feature/UserTest.php' => ['users'], + ]); + + $affected = $graph->affected(['database/migrations/2024_01_01_000000_create_orders_table.php']); + + expect($affected)->toBe(['tests/Feature/UserTest.php']); + }); + + it('selects nothing for an unrelated migration when no watch pattern covers it', function (): void { + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/UserTest.php', 'app/Models/User.php'); + $graph->replaceTestTables([ + 'tests/Feature/UserTest.php' => ['users'], + ]); + + $affected = $graph->affected(['database/migrations/2024_01_01_000000_create_orders_table.php']); + + expect($affected)->toBeEmpty(); + }); + it('falls back to watch patterns when no table usage was recorded at all', function (): void { $this->watchPatterns->add(['database/migrations/**' => 'tests/Feature']);