diff --git a/src/TestSuite/Migrator.php b/src/TestSuite/Migrator.php index dd160249..d62397c2 100644 --- a/src/TestSuite/Migrator.php +++ b/src/TestSuite/Migrator.php @@ -84,6 +84,8 @@ public function runMany( // Detect all connections involved, and mark those with changed status. $connectionsToDrop = []; $connectionsList = []; + $sets = []; + $historyGroups = []; foreach ($options as $i => $migrationSet) { $migrationSet += ['connection' => 'test']; $skip = $migrationSet['skip'] ?? []; @@ -95,9 +97,29 @@ public function runMany( $connectionsList[$connectionName] = ['name' => $connectionName, 'skip' => $skip]; } + // Sets sharing a connection and a plugin also share a migration history, + // while each of them only has its own source directory on disk. Group + // them so that a set is not told that its siblings' applied migrations + // are missing. + $groupKey = $connectionName . '|' . ($migrationSet['plugin'] ?? ''); + $historyGroups[$groupKey][] = $migrationSet; + $sets[] = ['options' => $migrationSet, 'skip' => $skip, 'group' => $groupKey]; + } + + $groupMigrationIds = []; + foreach ($historyGroups as $groupKey => $group) { + $groupMigrationIds[$groupKey] = $this->getSiblingMigrationIds($group); + } + + foreach ($sets as $set) { + $connectionName = $set['options']['connection']; + if (isset($connectionsToDrop[$connectionName])) { + continue; + } + $migrations = new Migrations(); - if (!isset($connectionsToDrop[$connectionName]) && $this->shouldDropTables($migrations, $migrationSet)) { - $connectionsToDrop[$connectionName] = ['name' => $connectionName, 'skip' => $skip]; + if ($this->shouldDropTables($migrations, $set['options'], $groupMigrationIds[$set['group']])) { + $connectionsToDrop[$connectionName] = ['name' => $connectionName, 'skip' => $set['skip']]; } } @@ -158,15 +180,48 @@ public function truncate(string $connection, array $skip = []): void } } + /** + * Collect the migration ids that exist on disk for a group of migration sets + * that share a single migration history. + * + * Returns an empty list for groups of one, where no sibling source exists. + * + * @param array> $group Migration sets sharing a history. + * @return array Migration ids present on disk, keyed by id. + */ + protected function getSiblingMigrationIds(array $group): array + { + if (count($group) < 2) { + return []; + } + + $ids = []; + foreach ($group as $options) { + $migrations = new Migrations(); + foreach ($migrations->status($options) as $migration) { + if ($migration['missing'] ?? false) { + continue; + } + $ids[$migration['id']] = true; + } + } + + return $ids; + } + /** * Detect if migrations have changed and the database needs to be wiped. * * @param \Migrations\Migrations $migrations The migrations service. * @param array $options The connection options. + * @param array $siblingMigrationIds Migration ids found in sources sharing the history. * @return bool */ - protected function shouldDropTables(Migrations $migrations, array $options): bool - { + protected function shouldDropTables( + Migrations $migrations, + array $options, + array $siblingMigrationIds = [], + ): bool { Log::write('debug', sprintf('Reading migrations status for %s...', $options['connection'])); $messages = [ @@ -175,6 +230,10 @@ protected function shouldDropTables(Migrations $migrations, array $options): boo ]; foreach ($migrations->status($options) as $migration) { if ($migration['status'] === 'up' && ($migration['missing'] ?? false)) { + // The migration belongs to another source sharing this history. + if (isset($siblingMigrationIds[$migration['id']])) { + continue; + } $messages['missing'][] = 'Applied but, missing Migration ' . sprintf('source=%s id=%s', $migration['name'], $migration['id']); } diff --git a/tests/TestCase/TestSuite/MigratorTest.php b/tests/TestCase/TestSuite/MigratorTest.php index cf2516c1..3a980440 100644 --- a/tests/TestCase/TestSuite/MigratorTest.php +++ b/tests/TestCase/TestSuite/MigratorTest.php @@ -33,27 +33,33 @@ class MigratorTest extends TestCase protected $restore; /** - * Get the migration table name for the Migrator plugin. + * Get the migration table name for a plugin, or for the application when null. * + * @param string|null $plugin The plugin name. * @return string */ - protected function getMigratorTableName(): string + protected function getMigratorTableName(?string $plugin = 'Migrator'): string { - return Configure::read('Migrations.legacyTables') === false - ? UnifiedMigrationsTableStorage::TABLE_NAME - : 'migrator_phinxlog'; + if (Configure::read('Migrations.legacyTables') === false) { + return UnifiedMigrationsTableStorage::TABLE_NAME; + } + + return $plugin === null ? 'phinxlog' : 'migrator_phinxlog'; } /** * Build a WHERE clause for filtering by plugin in unified mode. * + * @param string|null $plugin The plugin name. * @return array */ - protected function getMigratorWhereClause(): array + protected function getMigratorWhereClause(?string $plugin = 'Migrator'): array { - return Configure::read('Migrations.legacyTables') === false - ? ['plugin' => 'Migrator'] - : []; + if (Configure::read('Migrations.legacyTables') !== false) { + return []; + } + + return $plugin === null ? ['plugin IS' => null] : ['plugin' => $plugin]; } protected function makeInspectableMigrator(): Migrator @@ -218,8 +224,6 @@ public function testRunManyMultipleSkip(): void { $connection = ConnectionManager::get('test'); $this->skipIf($connection->getDriver() instanceof Postgres); - // Skip for unified mode - migration history detection works differently - $this->skipIf(Configure::read('Migrations.legacyTables') === false); $migrator = new Migrator(); // Run migrations for the first time. @@ -228,6 +232,9 @@ public function testRunManyMultipleSkip(): void ['plugin' => 'Migrator', 'source' => 'Migrations2'], ]); + // Forget one applied migration so that the next run has a reason to drop. + $this->forgetMigration('20211001000000'); + // Run migrations the second time. Skip clauses will cause problems. try { $migrator->runMany([ @@ -253,24 +260,35 @@ public function testTruncateAfterMigrations(): void $this->assertCount(0, $connection->selectQuery()->select(['*'])->from('migrator')->execute()->fetchAll()); } - private function setMigrationEndDateToYesterday(): void + private function setMigrationEndDateToYesterday(?string $plugin = 'Migrator'): void { $query = ConnectionManager::get('test')->updateQuery() - ->update($this->getMigratorTableName()) + ->update($this->getMigratorTableName($plugin)) ->set('end_time', ChronosDate::yesterday(), 'timestamp'); - $where = $this->getMigratorWhereClause(); + $where = $this->getMigratorWhereClause($plugin); if ($where) { $query->where($where); } $query->execute(); } - private function fetchMigrationEndDate(): ChronosDate + private function forgetMigration(string $version): void + { + $query = ConnectionManager::get('test')->deleteQuery() + ->delete($this->getMigratorTableName()) + ->where(['version' => $version]); + foreach ($this->getMigratorWhereClause() as $field => $value) { + $query->where([$field => $value]); + } + $query->execute(); + } + + private function fetchMigrationEndDate(?string $plugin = 'Migrator'): ChronosDate { $query = ConnectionManager::get('test')->selectQuery() ->select('end_time') - ->from($this->getMigratorTableName()); - $where = $this->getMigratorWhereClause(); + ->from($this->getMigratorTableName($plugin)); + $where = $this->getMigratorWhereClause($plugin); if ($where) { $query->where($where); } @@ -323,6 +341,49 @@ public function testSkipMigrationDroppingIfOnlyUpMigrationsWithTwoSetsOfMigratio $this->assertTrue($this->fetchMigrationEndDate()->isYesterday()); } + public function testSkipMigrationDroppingWithTwoSourcesSharingHistory(): void + { + // Both sets resolve to the same migration history: same connection and + // same plugin. Each source only sees its own files on disk, so the + // other source's applied migrations must not be treated as missing. + $sets = [ + ['plugin' => 'Migrator'], + ['plugin' => 'Migrator', 'source' => 'Migrations2'], + ]; + + $migrator = new Migrator(); + $migrator->runMany($sets, false); + + $this->setMigrationEndDateToYesterday(); + + $migrator->runMany($sets, false); + + // Ensure that the end time is unchanged, meaning that the tables were not + // dropped and the migrations were not re-run. + $this->assertTrue($this->fetchMigrationEndDate()->isYesterday()); + } + + public function testSkipMigrationDroppingWithTwoAppSourcesSharingHistory(): void + { + // Two application sources on the same connection: no plugin, so both + // record into the same migration history. + $sets = [ + ['source' => '../Plugin/Migrator/config/Migrations'], + ['source' => '../Plugin/Migrator/config/Migrations2'], + ]; + + $migrator = new Migrator(); + $migrator->runMany($sets, false); + + $this->setMigrationEndDateToYesterday(null); + + $migrator->runMany($sets, false); + + // Ensure that the end time is unchanged, meaning that the tables were not + // dropped and the migrations were not re-run. + $this->assertTrue($this->fetchMigrationEndDate(null)->isYesterday()); + } + public function testDropMigrationsIfDownMigrations(): void { // Skip for unified mode - migration history detection works differently