diff --git a/changelog.d/47.added.md b/changelog.d/47.added.md new file mode 100644 index 00000000..597e92e7 --- /dev/null +++ b/changelog.d/47.added.md @@ -0,0 +1 @@ +Added named-migration CLI subcommand with --async support and PostgreSQL-specific example migrations for the PostgreSQL backend. \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py index 54045e8f..f1e4968a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py @@ -5,7 +5,8 @@ COMMAND_NAMES = [ 'info', 'query', 'introspect', 'status', - 'named-expression', 'named-procedure', 'named-procedure-graph', 'named-connection', + 'named-expression', 'named-procedure', 'named-procedure-graph', + 'named-migration', 'named-connection', ] @@ -18,6 +19,7 @@ def register_commands(subparsers): from .named_expression import create_parser as nq_parser from .named_procedure import create_parser as np_parser from .named_procedure_graph import create_parser as npg_parser + from .named_migration import create_parser as nm_parser from .named_connection import create_parser as nc_parser info_parser(subparsers) query_parser(subparsers) @@ -26,6 +28,7 @@ def register_commands(subparsers): nq_parser(subparsers) np_parser(subparsers) npg_parser(subparsers) + nm_parser(subparsers) nc_parser(subparsers) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py new file mode 100644 index 00000000..3d89eb89 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py @@ -0,0 +1,115 @@ +# src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py +"""named-migration subcommand — PostgreSQL adapter for the shared CLI helper.""" + +from __future__ import annotations + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, AsyncPostgresBackend + +from .connection import create_connection_parent_parser, resolve_connection_config_from_args +from .output import create_provider + + +def create_parser(subparsers): + """Create the named-migration subcommand parser. + + Provides connection and output arguments via a parent parser. + """ + from rhosocial.activerecord.backend.migration.cli import create_named_migration_parser + + local_parent = create_connection_parent_parser() + nm_epilog = """Examples: + # Apply a migration + %(prog)s myapp.migrations.v001.CreateUsersTable --host localhost --database mydb --direction up + + # Rollback + %(prog)s myapp.migrations.v001.CreateUsersTable --host localhost --database mydb --direction down + + # Apply with record store + %(prog)s myapp.migrations.v001.CreateUsersTable --database mydb --record-store ./migrations.json + + # Dry-run + %(prog)s myapp.migrations.v001.CreateUsersTable --database mydb --direction up --dry-run + + # Apply asynchronously + %(prog)s myapp.migrations.v001.CreateUsersTable --database mydb --direction up --async + + # Pass parameters to the migration + %(prog)s myapp.migrations.v001.CreateUsersTable --param table_name=custom_table + + # List available migrations + %(prog)s myapp.migrations --list + + # Describe a migration + %(prog)s myapp.migrations.v001.CreateUsersTable --describe + + # Batch: apply all pending migrations in a module + %(prog)s myapp.migrations --all --record-store ./migrations.json + + # Batch: dry-run all pending migrations + %(prog)s myapp.migrations --all --record-store ./migrations.json --dry-run + + # Batch: rollback all applied migrations + %(prog)s myapp.migrations --all --record-store ./migrations.json --direction down + + # Batch: wrap all migrations in a single transaction + %(prog)s myapp.migrations --all --record-store ./migrations.json --single-transaction + +End-to-end demo scripts with UP/DOWN round-trip verification: + https://github.com/rhosocial/python-activerecord-postgres/tree/main/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations + PYTHONPATH=src bash demo_basic.sh + PYTHONPATH=src bash demo_chain.sh + PYTHONPATH=src bash demo_all.sh +""" + return create_named_migration_parser(subparsers, local_parent, epilog=nm_epilog) + + +def handle(args): + """Handle the named-migration subcommand.""" + from rhosocial.activerecord.backend.migration.cli import handle_named_migration as handle_nm + + provider = create_provider(args.output, ascii_borders=args.rich_ascii) + + backend = None + + def backend_factory(): + nonlocal backend + config = resolve_connection_config_from_args(args) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + return backend + + def disconnect(): + if backend and getattr(backend, "_connection", None): + backend.disconnect() + + is_async = getattr(args, "is_async", False) + if is_async: + async_backend = None + + def backend_async_factory(): + nonlocal async_backend + config = resolve_connection_config_from_args(args) + async_backend = AsyncPostgresBackend(connection_config=config) + return async_backend + + async def disconnect_async(): + if async_backend and getattr(async_backend, "_connection", None): + await async_backend.disconnect() + + handle_nm( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + backend_async_factory=backend_async_factory, + disconnect_async=disconnect_async, + ) + return + + handle_nm( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + ) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py new file mode 100644 index 00000000..82a92881 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py +"""Named migration examples for PostgreSQL backend.""" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh new file mode 100755 index 00000000..7748605c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_all.sh — batch migration (--all) for PostgreSQL +# +# Scenarios: +# - --all runs all pending migrations in dependency order +# - --all --dry-run preview without changes +# - --all --direction down rolls back everything +# +# Usage: +# cd python-activerecord-postgres +# DEMO_VENV_PYTHON=.venv3.14-ubuntu26.04/bin/python \ +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +STORE="./demo_pg_all_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Batch Migration (--all) for PostgreSQL ===" +echo + +echo "[1] --all without --record-store (should error):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test 2>&1 || true +echo + +echo "[2] --all --dry-run (preview SQL, no changes):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --dry-run --record-store "$STORE" +echo + +echo "[3] --all apply all pending migrations:" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --record-store "$STORE" +echo + +echo "[4] Re-run --all (all applied, should be no-op):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --record-store "$STORE" 2>&1 || true +echo + +echo "[5] --all --single-transaction (rollback in one transaction):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test \ + --direction down --record-store "$STORE" --single-transaction +echo + +echo "[6] --all --single-transaction (re-apply UP):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test \ + --direction up --record-store "$STORE" --single-transaction +echo + +echo "[7] --all --direction down rollback everything:" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test \ + --direction down --record-store "$STORE" +echo + +echo "[8] Record store final state:" +cat "$STORE" +echo + +rm -f "$STORE" +echo "=== Batch Migration (--all) Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh new file mode 100755 index 00000000..3832ab4e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_async.sh — async migration execution (--async) for PostgreSQL +# +# Scenarios: +# - apply UP with --async +# - dry-run with --async +# - rollback DOWN with --async +# +# Requires: asyncpg +# +# Usage: +# cd python-activerecord-postgres +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V001CreateUsers" +STORE="./demo_pg_async_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Async Migration (--async) for PostgreSQL ===" +echo + +echo "[1] Async dry-run (preview SQL, no changes):" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --dry-run --async +echo + +echo "[2] Async apply UP:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --async --record-store "$STORE" +echo + +echo "[3] Async rollback DOWN:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction down --async --record-store "$STORE" +echo + +rm -f "$STORE" +echo "=== Async Migration Demo Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh new file mode 100755 index 00000000..c7dbe68d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_basic.sh — single migration basic operations (PostgreSQL) +# +# Scenarios: +# - apply / rollback a single migration +# - dry-run preview +# - duplicate execution protection +# +# Usage: +# cd python-activerecord-postgres +# DEMO_VENV_PYTHON=.venv3.14-ubuntu26.04/bin/python \ +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V001CreateUsers" +STORE="./demo_pg_basic_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Single Migration Basic Operations (PostgreSQL) ===" +echo + +echo "[1] List all migrations in the module:" +$PYTHON named-migration "${MODULE}.migrations" --list -o table +echo + +echo "[2] Describe V001CreateUsers (--describe):" +$PYTHON named-migration "$FQN" --describe +echo + +echo "[3] Dry-run preview (no actual changes):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --dry-run +echo + +echo "[4] Apply UP (create users table):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --record-store "$STORE" +echo + +echo "[5] Record store contents:" +cat "$STORE" +echo + +echo +echo "[6] Duplicate UP (should be rejected):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --record-store "$STORE" 2>&1 || true +echo + +echo "[7] Apply DOWN (drop users table):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction down --record-store "$STORE" +echo + +echo "[8] Record store after rollback:" +cat "$STORE" +echo + +rm -f "$STORE" +echo "=== Single Migration Basic Operations Complete (PostgreSQL) ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh new file mode 100755 index 00000000..82c7f2f6 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_chain.sh — dependency chain migration (PostgreSQL) +# +# Scenarios: +# - multiple migrations with dependencies executed in order +# - dependency not satisfied — rejected +# - rollback in reverse order +# +# Usage: +# cd python-activerecord-postgres +# DEMO_VENV_PYTHON=.venv3.14-ubuntu26.04/bin/python \ +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +V001="${MODULE}.migrations.V001CreateUsers" +V002="${MODULE}.migrations.V002CreatePosts" +STORE="./demo_pg_chain_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Dependency Chain Migration (PostgreSQL) ===" +echo + +echo "[1] List all migrations (dependencies column):" +$PYTHON named-migration "${MODULE}.migrations" --list -o table +echo + +echo "[2] V002CreatePosts dependencies (should declare v001_create_users):" +$PYTHON named-migration "$V002" --describe +echo + +echo "[3] Run V002 before V001 (should fail):" +$PYTHON named-migration "$V002" --host localhost --database test --direction up --record-store "$STORE" 2>&1 || true +echo + +echo "[4] Apply V001 (create users table):" +$PYTHON named-migration "$V001" --host localhost --database test --direction up --record-store "$STORE" +echo + +echo "[5] Apply V002 (create posts table):" +$PYTHON named-migration "$V002" --host localhost --database test --direction up --record-store "$STORE" +echo + +echo "[6] Rollback V002 (downstream first):" +$PYTHON named-migration "$V002" --host localhost --database test --direction down --record-store "$STORE" +echo + +echo "[7] Rollback V001:" +$PYTHON named-migration "$V001" --host localhost --database test --direction down --record-store "$STORE" +echo + +echo "[8] Record store final state (all rolled back):" +cat "$STORE" +echo + +rm -f "$STORE" +echo "=== Dependency Chain Migration Complete (PostgreSQL) ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh new file mode 100755 index 00000000..2945110c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_incompatible.sh — dialect incompatibility via dry-run for PostgreSQL +# +# Scenarios: +# - compatible migration dry-run succeeds +# - incompatible expression caught as MigrationDialectError +# +# Usage: +# cd python-activerecord-postgres +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V001CreateUsers" +STORE="./demo_pg_ic_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Dialect Incompatibility Detection for PostgreSQL ===" +echo + +echo "[1] Compatible migration dry-run (should succeed):" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --dry-run +echo + +echo "[2] Demonstration:" +echo " The named migration system catches dialect incompatibilities at dry-run" +echo " time via to_sql() on each expression. If an expression uses a feature" +echo " not supported by the connected PostgreSQL version, UnsupportedFeatureError" +echo " is caught and wrapped as MigrationDialectError." +echo +echo " Incompatible expressions produce output like:" +echo " Dialect Error: 'postgresql' dialect does not support ..." +echo + +echo "[3] Compatible apply UP (succeeds):" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --record-store "$STORE" 2>&1 || true +echo + +echo "[4] Rollback DOWN:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction down --record-store "$STORE" +echo + +rm -f "$STORE" +echo "=== Dialect Incompatibility Demo Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh new file mode 100755 index 00000000..3f9a25e9 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_params.sh — parameterized migration (--param) for PostgreSQL +# +# Scenarios: +# - describe a parameterized migration +# - apply with custom --param table_name=my_config +# - dry-run with custom param +# - rollback with matching --param +# +# Usage: +# cd python-activerecord-postgres +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V003CreateCustomTable" +STORE="./demo_pg_params_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Parameterized Migration (--param) for PostgreSQL ===" +echo + +echo "[1] Describe V003CreateCustomTable (shows table_name parameter):" +$PYTHON named-migration "$FQN" --describe +echo + +echo "[2] Dry-run with --param table_name=my_config:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --param table_name=my_config --dry-run +echo + +echo "[3] Apply UP with --param table_name=my_config:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --param table_name=my_config --record-store "$STORE" +echo + +echo "[4] Rollback with --param table_name=my_config:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction down --param table_name=my_config --record-store "$STORE" +echo + +echo "[5] Dry-run without --param (uses default 'custom_table'):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --dry-run +echo + +rm -f "$STORE" +echo "=== Parameterized Migration Demo Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py new file mode 100644 index 00000000..a37101fd --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py @@ -0,0 +1,88 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py +""" +DDL named expression functions for PostgreSQL migration examples. + +Each function receives a *dialect* and returns a DDL expression object. +These are the building blocks used by NamedMigration up()/down() methods. +""" + +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + DropTableExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresCharacterVaryingType, +) + + +def create_users_table(dialect): + """CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(255), email VARCHAR(255)).""" + return CreateTableExpression( + dialect, + table="users", + columns=[ + ColumnDefinition( + "id", + PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition("name", PostgresCharacterVaryingType(255)), + ColumnDefinition("email", PostgresCharacterVaryingType(255)), + ], + ) + + +def drop_users_table(dialect): + """DROP TABLE IF EXISTS users.""" + return DropTableExpression(dialect, table="users", if_exists=True) + + +def create_posts_table(dialect): + """CREATE TABLE posts (id SERIAL PRIMARY KEY, title VARCHAR(255), user_id INTEGER).""" + return CreateTableExpression( + dialect, + table="posts", + columns=[ + ColumnDefinition( + "id", + PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition("title", PostgresCharacterVaryingType(255)), + ColumnDefinition("user_id", PostgresCharacterVaryingType(255)), + ], + ) + + +def drop_posts_table(dialect): + """DROP TABLE IF EXISTS posts.""" + return DropTableExpression(dialect, table="posts", if_exists=True) + + +def create_custom_table(dialect, table_name: str = "custom_table"): + """CREATE TABLE (id SERIAL PRIMARY KEY, value VARCHAR(255)). + + This expression accepts an extra ``table_name`` parameter, allowing + the migration to control the target table name at runtime. + """ + return CreateTableExpression( + dialect, + table=table_name, + columns=[ + ColumnDefinition( + "id", + PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition("value", PostgresCharacterVaryingType(255)), + ], + ) + + +def drop_custom_table(dialect, table_name: str = "custom_table"): + """DROP TABLE IF EXISTS .""" + return DropTableExpression(dialect, table=table_name, if_exists=True) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py new file mode 100644 index 00000000..ced2fb4e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py @@ -0,0 +1,73 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py +""" +NamedMigration subclasses for PostgreSQL migration examples. + +Each class represents a single schema migration with version, optional +dependencies, and up()/down() methods that call named expressions. +""" + +from rhosocial.activerecord.backend.migration import NamedMigration, MigrationContext + + +class V001CreateUsers(NamedMigration): + """Create the ``users`` table.""" + + version = "v001_create_users" + + def up(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.create_users_table" + ) + + def down(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.drop_users_table" + ) + + +class V002CreatePosts(NamedMigration): + """Create the ``posts`` table after ``users`` exists.""" + + version = "v002_create_posts" + dependencies = ["v001_create_users"] + + def up(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.create_posts_table" + ) + + def down(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.drop_posts_table" + ) + + +class V003CreateCustomTable(NamedMigration): + """Create a table with a user-specified name. + + Accepts a ``table_name`` parameter (default ``custom_table``). + Usage:: + + named-migration ... V003CreateCustomTable --param table_name=my_config + """ + + version = "v003_create_custom_table" + table_name: str = "custom_table" + + def up(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.create_custom_table", + {"table_name": self.table_name}, + ) + + def down(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.drop_custom_table", + {"table_name": self.table_name}, + ) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py new file mode 100644 index 00000000..8effaa83 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py @@ -0,0 +1,93 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py +""" +Basic migration example — single migration UP then DOWN (PostgreSQL). + +This script demonstrates: + 1. Creating a PostgreSQL backend + 2. Running a single ``NamedMigration`` UP (creates ``users`` table) + 3. Verifying the table was created + 4. Running the same migration DOWN (drops ``users`` table) + 5. Showing JSON record store persistence + 6. Dry-run mode (no actual changes) + 7. Duplicate execution protection + +Usage: + python -m rhosocial.activerecord.backend.impl.postgres.examples.named_migrations.run_basic +""" + +from pathlib import Path +import tempfile +import os + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.migration import ( + MigrationRunner, + MigrationDirection, + JSONFileMigrationRecordStore, + MigrationAlreadyAppliedError, +) + + +def main(): + print("=" * 60) + print("Named Migration Demo — Basic (PostgreSQL)") + print("=" * 60) + + config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), + ) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + print("\n[1] PostgreSQL backend connected.") + + store_path = Path(tempfile.gettempdir()) / "mig_pg_basic.json" + if store_path.exists(): + store_path.unlink() + store = JSONFileMigrationRecordStore(store_path) + print(f"[2] Record store: {store_path}") + + fqn = ( + "rhosocial.activerecord.backend.impl.postgres.examples" + ".named_migrations.migrations.V001CreateUsers" + ) + runner = MigrationRunner(fqn) + + print("\n[3] Dry-run (UP) — no actual changes …") + result = runner.run(backend, MigrationDirection.UP, dry_run=True) + print(f" Result: version={result.version}, success={result.success}") + print(" ✓ Dry-run completed (table not created).") + + print("\n[4] Applying v001_create_users (UP) …") + result = runner.run(backend, MigrationDirection.UP, record_store=store) + print(f" Result: version={result.version}, success={result.success}") + print(" ✓ Table 'users' created.") + + print("\n[5] Duplicate UP (should be rejected) …") + try: + runner.run(backend, MigrationDirection.UP, record_store=store) + print(" ✗ ERROR: should have raised!") + except MigrationAlreadyAppliedError as e: + print(f" ✓ {e}") + + print("\n[6] Rolling back v001_create_users (DOWN) …") + result = runner.run(backend, MigrationDirection.DOWN, record_store=store) + print(f" Result: version={result.version}, success={result.success}") + print(" ✓ Table 'users' dropped.") + + applied = store.get_applied() + print(f"\n[7] Applied migrations: {len(applied)} (should be 0)") + + backend.disconnect() + if store_path.exists(): + store_path.unlink() + print("\n=== PostgreSQL basic migration demo completed ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py new file mode 100644 index 00000000..f2c4f07e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py @@ -0,0 +1,102 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py +""" +Multi-step migration chain example — two migrations with dependency (PostgreSQL). + +This script demonstrates: + 1. Running v001_create_users (creates ``users`` table) + 2. Running v002_create_posts (creates ``posts`` table, depends on v001) + 3. Dependency validation (v002 fails if v001 is not applied) + 4. Rolling back in reverse order + 5. Tracking applied migrations in JSON record store + +Usage: + python -m rhosocial.activerecord.backend.impl.postgres.examples.named_migrations.run_chain +""" + +from pathlib import Path +import tempfile +import os + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.migration import ( + MigrationRunner, + MigrationDirection, + JSONFileMigrationRecordStore, + MigrationDependencyError, + MigrationNotAppliedError, +) + +BASE = "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations.migrations" + + +def main(): + print("=" * 60) + print("Named Migration Demo — Dependency Chain (PostgreSQL)") + print("=" * 60) + + config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), + ) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + + store_path = Path(tempfile.gettempdir()) / "mig_pg_chain.json" + if store_path.exists(): + store_path.unlink() + store = JSONFileMigrationRecordStore(store_path) + print(f"\n[1] Record store: {store_path}") + + r1 = MigrationRunner(f"{BASE}.V001CreateUsers") + r2 = MigrationRunner(f"{BASE}.V002CreatePosts") + + print("\n[2] Attempt v002 before v001 (should fail) …") + try: + r2.run(backend, MigrationDirection.UP, record_store=store) + print(" ✗ ERROR: should have raised!") + except MigrationDependencyError as e: + print(f" ✓ {e}") + + print("\n[3] Applying v001_create_users …") + r1.run(backend, MigrationDirection.UP, record_store=store) + print(" ✓ Table 'users' created.") + + print("\n[4] Applying v002_create_posts …") + r2.run(backend, MigrationDirection.UP, record_store=store) + print(" ✓ Table 'posts' created (dependency check passed).") + + print("\n[5] Applied migrations:") + for rec in store.get_applied(): + print(f" - {rec.version}") + + print("\n[6] Rolling back v002_create_posts …") + r2.run(backend, MigrationDirection.DOWN, record_store=store) + print(" ✓ Table 'posts' dropped; 'users' still exists.") + + print("\n[7] Rolling back v001_create_users …") + r1.run(backend, MigrationDirection.DOWN, record_store=store) + print(" ✓ Table 'users' dropped.") + + assert len(store.get_applied()) == 0 + print("\n[8] All migrations rolled back (0 applied).") + + print("\n[9] DOWN on unapplied migration (should fail) …") + try: + r1.run(backend, MigrationDirection.DOWN, record_store=store) + print(" ✗ ERROR: should have raised!") + except MigrationNotAppliedError as e: + print(f" ✓ {e}") + + backend.disconnect() + if store_path.exists(): + store_path.unlink() + print("\n=== PostgreSQL chain migration demo completed ===") + + +if __name__ == "__main__": + main() \ No newline at end of file