Files
MeshVault/tests/Integration/RepositoryDatabase.php
T

166 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use PDO;
use Phinx\Config\Config;
use Phinx\Migration\Manager;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/** Runs production migrations against SQLite, or an explicitly configured PostgreSQL database. */
final class RepositoryDatabase
{
/** @var array<string, array{schema: string, connection: PDO}> */
private static array $schemas = [];
/** @var array<string, Config> */
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);
}
}