diff --git a/README.md b/README.md index fa41e17..e003a8c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ Series and episode data comes from [TMDB](https://www.themoviedb.org/). 5. **First user:** create the initial admin with `php bin/create_user.php --admin`. +**Updating:** after pulling a new version, apply pending schema changes with +`php bin/migrate.php` (idempotent; see +[`sql/migrations/README.md`](sql/migrations/README.md)). + ## Translations UI translations are flat JSON files in `lang/` (English is the source diff --git a/bin/migrate.php b/bin/migrate.php new file mode 100755 index 0000000..5c49b44 --- /dev/null +++ b/bin/migrate.php @@ -0,0 +1,70 @@ +#!/usr/bin/php + $dbConfig */ +$dbConfig = $config->get('db', []); +$migrator = new Migrator(new Database($dbConfig), $projectDir . '/sql/migrations'); + +try { + // Applied-but-missing files hint at a renamed migration - warn, never fail. + foreach ($migrator->missingMigrations() as $name) { + fwrite(STDERR, "Warning: migration $name is recorded as applied but missing on disk.\n"); + } + + if ($status) { + printf("Applied: %d\n", count($migrator->appliedMigrations())); + $pending = $migrator->pendingMigrations(); + if ($pending === []) { + echo "Nothing pending.\n"; + } else { + echo "Pending:\n"; + foreach ($pending as $name) { + echo " $name\n"; + } + } + exit(0); + } + + $applied = $migrator->migrate(static function (string $name): void { + printf("Applied %s\n", $name); + }); + echo $applied === [] ? "Nothing to apply.\n" : sprintf("%d migration(s) applied.\n", count($applied)); +} catch (Throwable $e) { + fwrite(STDERR, $e->getMessage() . "\n"); + exit(1); +} diff --git a/deploy/SETUP.md b/deploy/SETUP.md index 044c889..d4a51c2 100644 --- a/deploy/SETUP.md +++ b/deploy/SETUP.md @@ -10,8 +10,9 @@ to the MySQL database configured in `config/config.php`. Create a MySQL/MariaDB database and user and load the schema. The exact statements and load command are documented in the header of -[`sql/schema.sql`](../sql/schema.sql). Catenvis has no migration tooling - -`sql/schema.sql` is the single source of truth for the schema. +[`sql/schema.sql`](../sql/schema.sql) - it always contains the full current +schema, so a fresh install needs nothing else. Later updates apply schema +changes as deltas via `php bin/migrate.php` (see "Ongoing operation"). ## 2. Prepare the server (via `ssh `) @@ -98,5 +99,13 @@ deploy/deploy.sh ``` - `config/config.php` and `catenvis.log` on the server are left untouched. -- There are no automatic database migrations: `sql/schema.sql` is the single - source of truth for the schema, so apply schema changes from there. +- Database migrations are not applied automatically. After deploying a + version that ships new files in `sql/migrations/`, run on the server: + + ```bash + php /var/www/catenvis/bin/migrate.php --status # read-only preview + php /var/www/catenvis/bin/migrate.php # apply pending migrations + ``` + + The command is idempotent and safe to re-run. If several instances share + one database, deploy the new code everywhere first, then migrate once. diff --git a/sql/migrations/README.md b/sql/migrations/README.md new file mode 100644 index 0000000..3dc598a --- /dev/null +++ b/sql/migrations/README.md @@ -0,0 +1,41 @@ +# Database migrations + +Delta files that bring an **existing** installation up to the current schema. +Fresh installs don't need them: [`sql/schema.sql`](../schema.sql) always +contains the full current schema (including markers for the migrations +already folded in). + +## Running + +```bash +php bin/migrate.php --status # read-only: show applied/pending state +php bin/migrate.php # apply all pending migrations +``` + +Applied migrations are tracked in the `schema_migrations` table, so the +command is idempotent and safe to re-run. Run it after every update that +ships new files in this directory. When one database is shared by several +instances, deploy the new code everywhere first, then migrate once. + +## Naming + +`NNN_short_description.sql` — zero-padded sequence number, next = highest + 1 +(e.g. `001_add_episode_overview.sql`). Files are applied in byte-sorted +filename order. + +## Rules + +- One logical change per file. +- Plain SQL only: statements separated by `;`; `--`, `#` and `/* ... */` + comments are fine. No `DELIMITER` switching (so no stored routines or + triggers with compound bodies). +- **Never edit or rename a migration once it may have been applied + somewhere** — the filename is its identity in `schema_migrations`. +- MySQL DDL is not transactional: if a statement fails mid-file, earlier + statements of that file stay applied and the migration stays unrecorded. + Prefer re-runnable statements (e.g. `ADD COLUMN IF NOT EXISTS` on + MariaDB/MySQL 8+) so a fixed migration can simply be retried. +- **Every migration must also be folded into `sql/schema.sql`**: apply the + same change to the table definitions there and add the marker line + `INSERT INTO schema_migrations (migration) VALUES ('NNN_....sql');` so + fresh installs don't re-apply it. diff --git a/sql/schema.sql b/sql/schema.sql index 6ff8fdd..59c791e 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -10,6 +10,11 @@ -- -- Step 2: load this schema into the catenvis database, e.g.: -- mysql -h -u catenvis -p catenvis < sql/schema.sql +-- +-- This file always reflects the CURRENT full schema - a fresh install needs +-- nothing else. Schema changes additionally ship as delta files in +-- sql/migrations/ so existing installations can catch up with +-- "php bin/migrate.php" (see sql/migrations/README.md). SET NAMES utf8mb4; @@ -136,3 +141,16 @@ CREATE TABLE IF NOT EXISTS login_attempts ( KEY idx_login_attempts_ip (ip, attempted_at), KEY idx_login_attempts_username (username, attempted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Applied schema migrations (see sql/migrations/README.md). bin/migrate.php +-- creates this table on existing installations if it is missing. +CREATE TABLE IF NOT EXISTS schema_migrations ( + migration VARCHAR(255) NOT NULL, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (migration) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Migrations already folded into this file count as applied on a fresh +-- install. When folding in a migration, also add its marker here: +-- INSERT INTO schema_migrations (migration) VALUES ('NNN_description.sql'); +-- (none yet) diff --git a/src/Migrator.php b/src/Migrator.php new file mode 100644 index 0000000..42a43b9 --- /dev/null +++ b/src/Migrator.php @@ -0,0 +1,243 @@ + + * @throws RuntimeException When the directory does not exist (broken checkout). + */ + public static function discoverMigrations(string $dir): array { + if (!is_dir($dir)) { + throw new RuntimeException("Migrations directory not found: $dir"); + } + + $names = []; + foreach (scandir($dir) ?: [] as $entry) { + if (str_ends_with($entry, '.sql') && is_file($dir . '/' . $entry)) { + $names[] = $entry; + } + } + sort($names); + + return $names; + } + + /** + * Splits an SQL script into single statements on ';', respecting quoted + * strings ('...', "...", `...`) and comments (-- , #, block comments). + * Comments are stripped; empty statements are dropped. + * + * @return list + */ + public static function splitStatements(string $sql): array { + $statements = []; + $buffer = ''; + $length = strlen($sql); + $i = 0; + + while ($i < $length) { + $char = $sql[$i]; + + // Line comments: "-- " (MySQL requires trailing whitespace) and "#". + if ($char === '#' || ($char === '-' && substr($sql, $i, 2) === '--' + && ($i + 2 >= $length || strpos(" \t\n\r", $sql[$i + 2]) !== false)) + ) { + $newline = strpos($sql, "\n", $i); + $i = $newline === false ? $length : $newline; + continue; // The newline itself is processed as a normal char. + } + + // Block comments: replaced by a single space (token separator). + if ($char === '/' && substr($sql, $i, 2) === '/*') { + $end = strpos($sql, '*/', $i + 2); + $i = $end === false ? $length : $end + 2; + $buffer .= ' '; + continue; + } + + // Quoted strings and identifiers are copied verbatim. + if ($char === "'" || $char === '"' || $char === '`') { + $buffer .= $char; + $i++; + while ($i < $length) { + // Backslash escapes apply inside strings, not identifiers. + if ($char !== '`' && $sql[$i] === '\\' && $i + 1 < $length) { + $buffer .= $sql[$i] . $sql[$i + 1]; + $i += 2; + continue; + } + if ($sql[$i] === $char) { + // A doubled quote is an escaped quote, not the end. + if ($i + 1 < $length && $sql[$i + 1] === $char) { + $buffer .= $char . $char; + $i += 2; + continue; + } + break; + } + $buffer .= $sql[$i]; + $i++; + } + if ($i < $length) { + $buffer .= $char; // Closing quote. + $i++; + } + continue; + } + + if ($char === ';') { + $statement = trim($buffer); + if ($statement !== '') { + $statements[] = $statement; + } + $buffer = ''; + $i++; + continue; + } + + $buffer .= $char; + $i++; + } + + $statement = trim($buffer); + if ($statement !== '') { + $statements[] = $statement; + } + + return $statements; + } + + /** + * All migration files shipped with this checkout, in application order. + * + * @return list + */ + public function availableMigrations(): array { + return self::discoverMigrations($this->migrationsDir); + } + + /** + * Migrations recorded as applied in the database. Strictly read-only: + * returns an empty list when the tracking table does not exist yet. + * + * @return list + */ + public function appliedMigrations(): array { + $exists = (int) $this->db->fetchValue( + 'SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = ?', + [self::TRACKING_TABLE] + ); + if ($exists === 0) { + return []; + } + + $names = []; + foreach ($this->db->fetchAll('SELECT migration FROM ' . self::TRACKING_TABLE . ' ORDER BY migration') as $row) { + $names[] = (string) $row['migration']; + } + + return $names; + } + + /** + * Migration files not yet applied to the database, in application order. + * + * @return list + */ + public function pendingMigrations(): array { + return array_values(array_diff($this->availableMigrations(), $this->appliedMigrations())); + } + + /** + * Applied migrations whose file no longer exists on disk - a renamed or + * deleted migration. Reported as a warning; renaming an applied migration + * would make it "pending" again under its new name. + * + * @return list + */ + public function missingMigrations(): array { + return array_values(array_diff($this->appliedMigrations(), $this->availableMigrations())); + } + + /** + * Applies all pending migrations in order and records each one. Creates + * the tracking table if it is missing (first run on an existing install). + * Aborts on the first failure, leaving the failed migration unrecorded so + * a fixed version is retried on the next run. + * + * @param callable(string): void|null $onApply Called after each applied file. + * @return list Migration names actually applied. + * @throws RuntimeException On unreadable, empty or failing migration files. + */ + public function migrate(?callable $onApply = null): array { + $this->db->execute( + 'CREATE TABLE IF NOT EXISTS ' . self::TRACKING_TABLE . ' ( + migration VARCHAR(255) NOT NULL, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (migration) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + + $applied = []; + foreach ($this->pendingMigrations() as $name) { + $path = $this->migrationsDir . '/' . $name; + $sql = file_get_contents($path); + if ($sql === false) { + throw new RuntimeException("Cannot read migration file: $path"); + } + + $statements = self::splitStatements($sql); + if ($statements === []) { + throw new RuntimeException("Migration $name contains no SQL statements."); + } + + foreach ($statements as $index => $statement) { + try { + $this->db->execute($statement); + } catch (PDOException $e) { + throw new RuntimeException( + sprintf('Migration %s failed at statement %d: %s', $name, $index + 1, $e->getMessage()), + 0, + $e + ); + } + } + + $this->db->execute('INSERT INTO ' . self::TRACKING_TABLE . ' (migration) VALUES (?)', [$name]); + $applied[] = $name; + if ($onApply !== null) { + $onApply($name); + } + } + + return $applied; + } +} diff --git a/tests/MigratorTest.php b/tests/MigratorTest.php new file mode 100644 index 0000000..b88729a --- /dev/null +++ b/tests/MigratorTest.php @@ -0,0 +1,151 @@ +tempDir !== null && is_dir($this->tempDir)) { + foreach (glob($this->tempDir . '/*') ?: [] as $entry) { + if (is_dir($entry)) { + @rmdir($entry); + } else { + @unlink($entry); + } + } + @rmdir($this->tempDir); + } + $this->tempDir = null; + } + + /** + * @param list $expected + */ + #[DataProvider('splitCases')] + public function testSplitStatements(string $sql, array $expected): void { + self::assertSame($expected, Migrator::splitStatements($sql)); + } + + /** + * @return iterable}> + */ + public static function splitCases(): iterable { + yield 'single statement without trailing semicolon' => [ + 'SELECT 1', + ['SELECT 1'], + ]; + + yield 'two statements are split and trimmed' => [ + "CREATE TABLE a (x INT);\nCREATE TABLE b (y INT);", + ['CREATE TABLE a (x INT)', 'CREATE TABLE b (y INT)'], + ]; + + yield 'trailing semicolon and whitespace produce no empty tail' => [ + "SELECT 1;\n\t \n", + ['SELECT 1'], + ]; + + yield 'semicolon inside single-quoted string does not split' => [ + "INSERT INTO t VALUES ('a;b');", + ["INSERT INTO t VALUES ('a;b')"], + ]; + + yield 'semicolon inside double-quoted string does not split' => [ + 'SELECT "x;y";', + ['SELECT "x;y"'], + ]; + + yield 'backslash-escaped quote keeps the string open' => [ + "INSERT INTO t VALUES ('a\\';b');", + ["INSERT INTO t VALUES ('a\\';b')"], + ]; + + yield 'doubled quote is an escaped quote, not the end' => [ + "SELECT 'a''b;c';", + ["SELECT 'a''b;c'"], + ]; + + yield 'line comment with semicolon is stripped' => [ + "SELECT 1; -- trailing; comment\nSELECT 2;", + ['SELECT 1', 'SELECT 2'], + ]; + + yield 'double dash without whitespace is not a comment' => [ + 'SELECT 1--2;', + ['SELECT 1--2'], + ]; + + yield 'hash comment is stripped' => [ + "# leading; comment\nSELECT 1;", + ['SELECT 1'], + ]; + + yield 'block comment with semicolon is stripped' => [ + "/* block;\ncomment */SELECT 1;", + ['SELECT 1'], + ]; + + yield 'backtick identifier containing semicolon' => [ + 'SELECT `a;b` FROM t;', + ['SELECT `a;b` FROM t'], + ]; + + yield 'empty input' => ['', []]; + + yield 'whitespace-only input' => [" \n\t ", []]; + + yield 'comment-only input' => ["-- nothing here\n/* really; nothing */", []]; + } + + public function testDiscoverMigrationsReturnsSortedBasenames(): void { + $dir = $this->makeTempDir(); + file_put_contents($dir . '/002_second.sql', 'SELECT 2;'); + file_put_contents($dir . '/010_tenth.sql', 'SELECT 10;'); + file_put_contents($dir . '/001_first.sql', 'SELECT 1;'); + + self::assertSame( + ['001_first.sql', '002_second.sql', '010_tenth.sql'], + Migrator::discoverMigrations($dir) + ); + } + + public function testDiscoverMigrationsIgnoresOtherFilesAndDirectories(): void { + $dir = $this->makeTempDir(); + file_put_contents($dir . '/001_first.sql', 'SELECT 1;'); + file_put_contents($dir . '/README.md', 'docs'); + mkdir($dir . '/subdir.sql'); // Directory despite the suffix. + + self::assertSame(['001_first.sql'], Migrator::discoverMigrations($dir)); + } + + public function testDiscoverMigrationsReturnsEmptyListForEmptyDirectory(): void { + self::assertSame([], Migrator::discoverMigrations($this->makeTempDir())); + } + + public function testDiscoverMigrationsThrowsForMissingDirectory(): void { + $this->expectException(RuntimeException::class); + + Migrator::discoverMigrations(sys_get_temp_dir() . '/catenvis-does-not-exist-' . uniqid()); + } + + private function makeTempDir(): string { + $dir = sys_get_temp_dir() . '/catenvis-mig-' . uniqid(); + mkdir($dir, 0777, true); + $this->tempDir = $dir; + + return $dir; + } +}