*/ private static array $schemas = []; /** @var array */ private static array $configs = []; public static function migrateTo(string $path, int $target): void { self::runTarget($path, $target, false); } public static function rollbackTo(string $path, int $target): void { self::runTarget($path, $target, true); } private static function runTarget(string $path, int $target, bool $down): void { $config = self::$configs[$path] ?? throw new \LogicException('Migrate this isolated path first.'); $manager = new Manager($config, new ArrayInput([]), new NullOutput()); $pdo = $config->getEnvironment('test')['connection']; if ($pdo->inTransaction()) { throw new \LogicException('Migration helpers require no active caller transaction.'); } try { if ($down) { $manager->rollback('test', $target); } else { $manager->migrate('test', $target); } } catch (\Throwable $error) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $error; } finally { if (isset(self::$schemas[$path])) { $pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"'); } } } public static function postgresConfigured(): bool { $dsn = getenv('MESHVAULT_TEST_PG_DSN'); return $dsn !== false && $dsn !== ''; } /** Never a hand-maintained test schema: drift must fail the suite. */ public static function migrate(string $path, ?int $target = null): PDO { if (isset(self::$configs[$path]) || isset(self::$schemas[$path])) { throw new \LogicException('Clean up the previous database before migrating this path again.'); } $environment = ['adapter' => 'sqlite']; if (self::postgresConfigured()) { $pdo = self::postgresConnection(); $schema = 'meshvault_test_' . bin2hex(random_bytes(16)); $pdo->exec('CREATE SCHEMA "' . $schema . '"'); self::$schemas[$path] = ['schema' => $schema, 'connection' => $pdo]; $pdo->exec('SET search_path TO "' . $schema . '"'); $environment = ['adapter' => 'pgsql', 'schema' => $schema]; } else { $pdo = self::connect($path); } try { $config = new Config([ 'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'], 'environments' => [ 'default_migration_table' => 'phinxlog', 'default_database' => 'test', 'test' => $environment + ['connection' => $pdo], ], ]); self::$configs[$path] = $config; (new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test', $target); if (isset(self::$schemas[$path])) { // Phinx may add public to search_path; repository queries must remain isolated. $pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"'); } return $pdo; } catch (\Throwable $error) { self::cleanup($path); throw $error; } } public static function connect(string $path): PDO { if (isset(self::$schemas[$path])) { $pdo = self::postgresConnection(); $pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"'); return $pdo; } if (self::postgresConfigured()) { throw new \LogicException('Migrate this path before connecting to PostgreSQL.'); } $pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA busy_timeout = 5000'); return $pdo; } public static function cleanup(string $path): void { unset(self::$configs[$path]); if (isset(self::$schemas[$path])) { ['schema' => $schema, 'connection' => $pdo] = self::$schemas[$path]; if ($pdo->inTransaction()) { $pdo->rollBack(); } // Only identifiers generated and registered by migrate() can reach this statement. $pdo->exec('DROP SCHEMA "' . $schema . '" CASCADE'); unset(self::$schemas[$path]); } if (is_file($path)) { unlink($path); } } private static function postgresConnection(): PDO { $dsn = (string) getenv('MESHVAULT_TEST_PG_DSN'); if (!str_starts_with($dsn, 'pgsql:')) { throw new \InvalidArgumentException('MESHVAULT_TEST_PG_DSN must be a pgsql: DSN.'); } return new PDO( $dsn, ($user = getenv('MESHVAULT_TEST_PG_USER')) === false ? null : $user, ($password = getenv('MESHVAULT_TEST_PG_PASSWORD')) === false ? null : $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); } public static function directory(string $prefix): string { $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'meshvault-' . $prefix . '-' . bin2hex(random_bytes(6)); mkdir($path, 0777, true); return (string) realpath($path); } public static function remove(string $path): void { foreach (array_diff(@scandir($path) ?: [], ['.', '..']) as $entry) { $child = $path . DIRECTORY_SEPARATOR . $entry; is_dir($child) ? self::remove($child) : @unlink($child); } @rmdir($path); } }