From 85e1042468215d0e79fd61045d8470d23945faa7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 24 Jun 2026 23:14:57 +0800 Subject: [PATCH 1/6] feat(cli): add named-migration subcommand (PostgreSQL adapter) Registers the 'named-migration' CLI subcommand for the PostgreSQL backend. Delegates to the shared implementation in rhosocial.activerecord.backend.migration.cli. - cli/named_migration.py: parser + handler adapter - cli/__init__.py: register in COMMAND_NAMES and register_commands() --- .../backend/impl/postgres/cli/__init__.py | 5 +- .../impl/postgres/cli/named_migration.py | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py 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..98158ded --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py @@ -0,0 +1,67 @@ +# 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 + +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 + + # List available migrations + %(prog)s myapp.migrations --list + + # Describe a migration + %(prog)s myapp.migrations.v001.CreateUsersTable --describe +""" + 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() + + handle_nm( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + ) From 7a299e06ae68b5a2a1202c7ff000d8b527df0e3d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 27 Jun 2026 22:22:51 +0800 Subject: [PATCH 2/6] feat(migration): add --async support, example migrations, and demo scripts for PostgreSQL - Extend named_migration.py adapter with --async support (AsyncPostgresBackend) - Add extensive CLI epilog with 12 scenario examples and demo script references - Create examples/named_migrations/ with migrations.py, expressions.py, run_basic.py, run_chain.py - Add demo_basic.sh, demo_chain.sh, demo_all.sh end-to-end shell scripts - expressions.py uses PostgresSerialType and PostgresCharacterVaryingType --- .../impl/postgres/cli/named_migration.py | 52 ++++++++- .../examples/named_migrations/__init__.py | 2 + .../examples/named_migrations/demo_all.sh | 56 ++++++++++ .../examples/named_migrations/demo_basic.sh | 66 ++++++++++++ .../examples/named_migrations/demo_chain.sh | 66 ++++++++++++ .../examples/named_migrations/expressions.py | 63 +++++++++++ .../examples/named_migrations/migrations.py | 46 ++++++++ .../examples/named_migrations/run_basic.py | 93 ++++++++++++++++ .../examples/named_migrations/run_chain.py | 102 ++++++++++++++++++ 9 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py index 98158ded..3d89eb89 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py @@ -3,7 +3,7 @@ from __future__ import annotations -from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +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 @@ -30,11 +30,35 @@ def create_parser(subparsers): # 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) @@ -59,9 +83,33 @@ 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 100644 index 00000000..261b2a94 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh @@ -0,0 +1,56 @@ +#!/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 all pending migrations:" +$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 --direction down rollback everything:" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --direction down --record-store "$STORE" +echo + +echo "[6] 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_basic.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh new file mode 100644 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 100644 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/expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py new file mode 100644 index 00000000..a3902eef --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py @@ -0,0 +1,63 @@ +# 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(size=255)), + ColumnDefinition("email", PostgresCharacterVaryingType(size=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(size=255)), + ColumnDefinition("user_id", PostgresCharacterVaryingType(size=255)), + ], + ) + + +def drop_posts_table(dialect): + """DROP TABLE IF EXISTS posts.""" + return DropTableExpression(dialect, table="posts", 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..e82837ba --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py @@ -0,0 +1,46 @@ +# 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" + ) \ 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 From e64b944943590faf8999624fd47692dec110d918 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 30 Jun 2026 19:35:17 +0800 Subject: [PATCH 3/6] chore: add changelog fragment for PR #47 --- changelog.d/47.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/47.added.md 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 From 78e76fbdd35dea17d2b8bd8ddb34be7dd3cb5f50 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 30 Jun 2026 19:51:43 +0800 Subject: [PATCH 4/6] chore: add parameterized migration, async, incompatible, and single-transaction demo scripts for PostgreSQL --- .../examples/named_migrations/demo_all.sh | 19 +++++-- .../examples/named_migrations/demo_async.sh | 49 ++++++++++++++++ .../examples/named_migrations/demo_basic.sh | 0 .../examples/named_migrations/demo_chain.sh | 0 .../named_migrations/demo_incompatible.sh | 56 +++++++++++++++++++ .../examples/named_migrations/demo_params.sh | 56 +++++++++++++++++++ .../examples/named_migrations/expressions.py | 27 ++++++++- .../examples/named_migrations/migrations.py | 27 +++++++++ 8 files changed, 229 insertions(+), 5 deletions(-) mode change 100644 => 100755 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh create mode 100755 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh mode change 100644 => 100755 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh mode change 100644 => 100755 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh create mode 100755 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh create mode 100755 src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh 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 old mode 100644 new mode 100755 index 261b2a94..7748605c --- 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 @@ -32,7 +32,7 @@ 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 all pending migrations:" +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 @@ -44,11 +44,22 @@ 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 --direction down rollback everything:" -$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --direction down --record-store "$STORE" +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] Record store final state:" +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 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 old mode 100644 new mode 100755 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 old mode 100644 new mode 100755 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 index a3902eef..e051290e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py @@ -60,4 +60,29 @@ def create_posts_table(dialect): def drop_posts_table(dialect): """DROP TABLE IF EXISTS posts.""" - return DropTableExpression(dialect, table="posts", if_exists=True) \ No newline at end of file + 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(size=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 index e82837ba..ced2fb4e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py @@ -43,4 +43,31 @@ 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 From 0f134898d468be6546ae11454bc23e2dd93ec510 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 30 Jun 2026 20:37:00 +0800 Subject: [PATCH 5/6] fix(postgres): use positional arg instead of keyword 'size' for PostgresCharacterVaryingType --- .../postgres/examples/named_migrations/expressions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index e051290e..a37101fd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py @@ -30,8 +30,8 @@ def create_users_table(dialect): PostgresSerialType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition("name", PostgresCharacterVaryingType(size=255)), - ColumnDefinition("email", PostgresCharacterVaryingType(size=255)), + ColumnDefinition("name", PostgresCharacterVaryingType(255)), + ColumnDefinition("email", PostgresCharacterVaryingType(255)), ], ) @@ -52,8 +52,8 @@ def create_posts_table(dialect): PostgresSerialType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition("title", PostgresCharacterVaryingType(size=255)), - ColumnDefinition("user_id", PostgresCharacterVaryingType(size=255)), + ColumnDefinition("title", PostgresCharacterVaryingType(255)), + ColumnDefinition("user_id", PostgresCharacterVaryingType(255)), ], ) @@ -78,7 +78,7 @@ def create_custom_table(dialect, table_name: str = "custom_table"): PostgresSerialType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition("value", PostgresCharacterVaryingType(size=255)), + ColumnDefinition("value", PostgresCharacterVaryingType(255)), ], ) From 02cfff28cc3aa4bdb795fbdfa12b75e618a3e7bd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 30 Jun 2026 21:41:49 +0800 Subject: [PATCH 6/6] ci: trigger rebuild