feat: introduce universal database migration system

This commit is contained in:
mmacierzanka committed 2026-09-30 17:55:35 +02:00
1 parent 6f50fce3f9
commit 1584b5fbd0
6 files changed
+192 -32

No files matched your search

+6 -29
View File
@@ -8,6 +8,9 @@ from typing import Any, Iterator
import psycopg
from zyrarbot.storage.adapters.postgres.migration_runner import MigrationRunner
from zyrarbot.storage.adapters.postgres.migrations import MIGRATIONS
logger = logging.getLogger(__name__)
@@ -30,33 +33,7 @@ class PostgresDatabase:
"password": password,
}
with self.connect() as connection:
schema = """
CREATE TABLE IF NOT EXISTS guilds (
id BIGINT PRIMARY KEY,
notification_channel_id BIGINT,
default_plays INTEGER
);
CREATE TABLE IF NOT EXISTS history_entries (
guild_id BIGINT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT,
duration DOUBLE PRECISION,
is_local BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT history_entries_guild_position_key UNIQUE (guild_id, position)
);
CREATE TABLE IF NOT EXISTS queue_snapshots (
guild_id BIGINT PRIMARY KEY REFERENCES guilds(id) ON DELETE CASCADE,
tracks JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY
);
"""
for statement in schema.split(";"):
if statement.strip():
connection.execute(statement)
MigrationRunner(MIGRATIONS).apply_pending(connection)
@contextmanager
def connect(self) -> Iterator[psycopg.Connection[Any]]:
@@ -67,7 +44,7 @@ class PostgresDatabase:
"""Import legacy JSON files once, retaining the source files as a backup."""
base = Path(config_dir)
with self.connect() as connection:
if connection.execute("SELECT 1 FROM migrations WHERE name='json-v1'").fetchone():
if connection.execute("SELECT 1 FROM data_imports WHERE name='json-v1'").fetchone():
return
config_path = base / "config.json"
@@ -107,7 +84,7 @@ class PostgresDatabase:
"ON CONFLICT(guild_id) DO NOTHING",
(int(path.stem), json.dumps(tracks, ensure_ascii=False)),
)
connection.execute("INSERT INTO migrations(name) VALUES ('json-v1')")
connection.execute("INSERT INTO data_imports(name) VALUES ('json-v1')")
def _read_json(path: Path) -> dict[str, Any]:
@@ -0,0 +1,80 @@
from __future__ import annotations
import argparse
import os
import psycopg
from zyrarbot.storage.adapters.postgres.migrations.base import Migration
class MigrationRunner:
"""Apply registered PostgreSQL migrations once, in version order."""
def __init__(self, migrations: tuple[Migration, ...]):
versions = [migration.version for migration in migrations]
if len(versions) != len(set(versions)):
raise ValueError("Migration versions must be unique")
self._migrations = tuple(sorted(migrations, key=lambda migration: migration.version))
def apply_pending(self, connection: psycopg.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
rows = connection.execute("SELECT version FROM schema_migrations").fetchall()
applied_versions = {row[0] for row in rows}
for migration in self._migrations:
if migration.version in applied_versions:
continue
with connection.transaction():
migration.apply(connection)
connection.execute(
"INSERT INTO schema_migrations(version, name) VALUES (%s, %s)",
(migration.version, migration.name),
)
def rollback_to(self, connection: psycopg.Connection, target_version: int = 0) -> None:
"""Roll back applied migrations newer than target_version."""
rows = connection.execute("SELECT version FROM schema_migrations").fetchall()
applied_versions = {row[0] for row in rows}
known_versions = {migration.version for migration in self._migrations}
unknown_versions = applied_versions - known_versions
if unknown_versions:
raise ValueError(f"No registered migrations for versions: {sorted(unknown_versions)}")
for migration in reversed(self._migrations):
if migration.version <= target_version or migration.version not in applied_versions:
continue
with connection.transaction():
migration.rollback(connection)
connection.execute(
"DELETE FROM schema_migrations WHERE version = %s", (migration.version,)
)
def main() -> None:
parser = argparse.ArgumentParser(description="Manage PostgreSQL schema migrations")
parser.add_argument("action", choices=("rollback",))
parser.add_argument("--to", type=int, default=0, dest="target_version")
args = parser.parse_args()
with psycopg.connect(
host=os.environ.get("POSTGRES_HOST", "localhost"),
port=int(os.environ.get("POSTGRES_PORT", "5432")),
dbname=os.environ.get("POSTGRES_DB", "zyrarbot"),
user=os.environ.get("POSTGRES_USER", "zyrarbot"),
password=os.environ.get("POSTGRES_PASSWORD", "zyrarbot"),
) as connection:
MigrationRunner(MIGRATIONS).rollback_to(connection, args.target_version)
if __name__ == "__main__":
from zyrarbot.storage.adapters.postgres.migrations import MIGRATIONS
main()
@@ -0,0 +1,57 @@
from __future__ import annotations
import psycopg
class InitialSchemaMigration:
"""Create the initial application schema."""
version = 1
name = "initial_schema"
def apply(self, connection: psycopg.Connection) -> None:
connection.execute(
"""
CREATE TABLE guilds (
id BIGINT PRIMARY KEY,
notification_channel_id BIGINT,
default_plays INTEGER
)
"""
)
connection.execute(
"""
CREATE TABLE history_entries (
guild_id BIGINT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT,
duration DOUBLE PRECISION,
is_local BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT history_entries_guild_position_key UNIQUE (guild_id, position)
)
"""
)
connection.execute(
"""
CREATE TABLE queue_snapshots (
guild_id BIGINT PRIMARY KEY REFERENCES guilds(id) ON DELETE CASCADE,
tracks JSONB NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE data_imports (
name TEXT PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def rollback(self, connection: psycopg.Connection) -> None:
connection.execute("DROP TABLE data_imports")
connection.execute("DROP TABLE queue_snapshots")
connection.execute("DROP TABLE history_entries")
connection.execute("DROP TABLE guilds")
@@ -0,0 +1,9 @@
"""Ordered PostgreSQL schema migrations."""
from importlib import import_module
InitialSchemaMigration = import_module(
".0001_initial_schema", __name__
).InitialSchemaMigration
MIGRATIONS = (InitialSchemaMigration(),)
@@ -0,0 +1,14 @@
from __future__ import annotations
from typing import Protocol
import psycopg
class Migration(Protocol):
version: int
name: str
def apply(self, connection: psycopg.Connection) -> None: ...
def rollback(self, connection: psycopg.Connection) -> None: ...