|
|
@@ -0,0 +1,126 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Db;
|
|
|
+
|
|
|
+use PDO;
|
|
|
+use RuntimeException;
|
|
|
+
|
|
|
+/**
|
|
|
+ * Dirt-simple forward-only migration runner.
|
|
|
+ *
|
|
|
+ * - Scans `migrations/` for files matching `NNN_*.sql` (3+ digit prefix).
|
|
|
+ * - Records applied versions in `schema_version(version, filename, applied_at)`.
|
|
|
+ * - Each file is executed in a single transaction. No down-migrations.
|
|
|
+ *
|
|
|
+ * Safe to call on every request; becomes a cheap no-op once current.
|
|
|
+ */
|
|
|
+final class Migrator
|
|
|
+{
|
|
|
+ private PDO $pdo;
|
|
|
+ private string $dir;
|
|
|
+
|
|
|
+ public function __construct(PDO $pdo, ?string $migrationsDir = null)
|
|
|
+ {
|
|
|
+ $this->pdo = $pdo;
|
|
|
+ $this->dir = $migrationsDir ?? dirname(__DIR__, 2) . '/migrations';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Run all pending migrations. Returns the list of files applied this call. */
|
|
|
+ public function migrate(): array
|
|
|
+ {
|
|
|
+ $this->ensureVersionTable();
|
|
|
+
|
|
|
+ $applied = $this->appliedVersions();
|
|
|
+ $files = $this->discover();
|
|
|
+
|
|
|
+ $ran = [];
|
|
|
+ foreach ($files as [$version, $filename, $fullPath]) {
|
|
|
+ if (isset($applied[$version])) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $this->apply($version, $filename, $fullPath);
|
|
|
+ $ran[] = $filename;
|
|
|
+ }
|
|
|
+ return $ran;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function currentVersion(): int
|
|
|
+ {
|
|
|
+ $this->ensureVersionTable();
|
|
|
+ $stmt = $this->pdo->query('SELECT COALESCE(MAX(version), 0) AS v FROM schema_version');
|
|
|
+ $row = $stmt->fetch();
|
|
|
+ return (int) ($row['v'] ?? 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function ensureVersionTable(): void
|
|
|
+ {
|
|
|
+ $this->pdo->exec(<<<SQL
|
|
|
+ CREATE TABLE IF NOT EXISTS schema_version (
|
|
|
+ version INTEGER PRIMARY KEY,
|
|
|
+ filename TEXT NOT NULL,
|
|
|
+ applied_at TEXT NOT NULL
|
|
|
+ )
|
|
|
+ SQL);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @return array<int,true> version => true */
|
|
|
+ private function appliedVersions(): array
|
|
|
+ {
|
|
|
+ $out = [];
|
|
|
+ $stmt = $this->pdo->query('SELECT version FROM schema_version');
|
|
|
+ foreach ($stmt as $row) {
|
|
|
+ $out[(int) $row['version']] = true;
|
|
|
+ }
|
|
|
+ return $out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @return list<array{0:int,1:string,2:string}> [version, filename, fullPath] sorted by version asc
|
|
|
+ */
|
|
|
+ private function discover(): array
|
|
|
+ {
|
|
|
+ if (!is_dir($this->dir)) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ $entries = scandir($this->dir) ?: [];
|
|
|
+ $out = [];
|
|
|
+ foreach ($entries as $name) {
|
|
|
+ if (!preg_match('/^(\d{3,})_[A-Za-z0-9_\-]+\.sql$/', $name, $m)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $version = (int) $m[1];
|
|
|
+ $out[] = [$version, $name, $this->dir . DIRECTORY_SEPARATOR . $name];
|
|
|
+ }
|
|
|
+ usort($out, static fn($a, $b) => $a[0] <=> $b[0]);
|
|
|
+ return $out;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function apply(int $version, string $filename, string $fullPath): void
|
|
|
+ {
|
|
|
+ $sql = file_get_contents($fullPath);
|
|
|
+ if ($sql === false) {
|
|
|
+ throw new RuntimeException("Cannot read migration: {$fullPath}");
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->pdo->beginTransaction();
|
|
|
+ try {
|
|
|
+ // PDO::exec accepts multiple statements separated by ';' on sqlite.
|
|
|
+ $this->pdo->exec($sql);
|
|
|
+ $stmt = $this->pdo->prepare(
|
|
|
+ 'INSERT INTO schema_version (version, filename, applied_at) VALUES (?, ?, ?)'
|
|
|
+ );
|
|
|
+ $stmt->execute([$version, $filename, gmdate('Y-m-d\TH:i:s\Z')]);
|
|
|
+ $this->pdo->commit();
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ $this->pdo->rollBack();
|
|
|
+ throw new RuntimeException(
|
|
|
+ "Migration failed ({$filename}): " . $e->getMessage(),
|
|
|
+ 0,
|
|
|
+ $e
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|