diff --git a/README.md b/README.md index 2db2d01..58d0c80 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ adds a few more (`—` in the library column): | `--target`, `-t` | `target` | DSN of the target (desired) database. Falls back to the `PGMIG_TARGET` environment variable. | | `--index-concurrently`, `-C` | `index_concurrently` | Whether to emit `CREATE`/`DROP INDEX` (including `CREATE UNIQUE INDEX`) with `CONCURRENTLY`. Using `CONCURRENTLY` avoids blocking index read/write operations, but takes longer to execute and cannot be run inside a transaction block. | | `--ignore-extension-version` | `ignore_extension_version` | Names of extensions whose version mismatch is ignored: no `ALTER EXTENSION ... UPDATE TO` is emitted for them. Repeatable on the CLI; a list of names in the library. | +| `--ignore-schema` | `ignore_schemas` | Schema names to exclude from the diff entirely: their tables and every other object, and the `CREATE`/`DROP` of the schema itself, are ignored (even object kinds pgmig cannot otherwise process). The schema must be isolated — if it shares any dependency with a kept schema (a foreign key, a view read, a cross-schema type, …), pgmig errors rather than emit a migration that would fail at apply. Repeatable on the CLI; a list of names in the library. | | `--include-owner` | `include_owner` | Emit `ALTER ... OWNER TO` statements to reconcile ownership. Off by default: ownership references cluster-level roles that routinely differ across environments, so it is not part of the default convergence. | | `--include-grants` | `include_grants` | Also emit named-role `GRANT`/`REVOKE`. `PUBLIC` grants are always diffed (portable and apply-safe); named-role grants reference cluster-level roles that diverge across environments and fail on apply when the role is absent on the target, so they are opt-in. | | `--output`, `-o` | — | Write the migration SQL to this file instead of stdout. | diff --git a/linters/cspell/words.txt b/linters/cspell/words.txt index d3aeb40..abc5a09 100644 --- a/linters/cspell/words.txt +++ b/linters/cspell/words.txt @@ -91,6 +91,7 @@ indkey refclassid refobjid refobjsubid +objsubid regclass conkey conname diff --git a/src/pgmig/_api.py b/src/pgmig/_api.py index 5b719d1..9043c6e 100644 --- a/src/pgmig/_api.py +++ b/src/pgmig/_api.py @@ -12,6 +12,7 @@ async def agenerate( target: str, index_concurrently: bool = False, ignore_extension_version: Sequence[str] = (), + ignore_schemas: Sequence[str] = (), include_owner: bool = False, include_grants: bool = False, ) -> str: @@ -26,6 +27,8 @@ async def agenerate( and cannot be run inside a transaction block. ignore_extension_version: Names of extensions whose version mismatch is ignored: no ALTER EXTENSION ... UPDATE TO is emitted for them. Empty (default) ignores none. + ignore_schemas: Schema names to exclude from the diff entirely -- their tables and every other object, + and the create/drop of the schema itself, are ignored. Empty (default) ignores none. include_owner: Emit ALTER ... OWNER TO statements to reconcile ownership. Off by default: ownership references cluster-level roles that routinely differ across environments, so it is not part of the default convergence. @@ -33,7 +36,10 @@ async def agenerate( named-role grants (role-dependent, may fail at apply) are opt-in. """ # Introspect both databases concurrently. - source_result, target_result = await asyncio.gather(introspect_db(source), introspect_db(target)) + source_result, target_result = await asyncio.gather( + introspect_db(dsn=source, ignore_schemas=ignore_schemas), + introspect_db(dsn=target, ignore_schemas=ignore_schemas), + ) # Generate migration SQL. return get_diff( @@ -52,6 +58,7 @@ def generate( target: str, index_concurrently: bool = False, ignore_extension_version: Sequence[str] = (), + ignore_schemas: Sequence[str] = (), include_owner: bool = False, include_grants: bool = False, ) -> str: @@ -66,6 +73,8 @@ def generate( and cannot be run inside a transaction block. ignore_extension_version: Names of extensions whose version mismatch is ignored: no ALTER EXTENSION ... UPDATE TO is emitted for them. Empty (default) ignores none. + ignore_schemas: Schema names to exclude from the diff entirely -- their tables and every other object, + and the create/drop of the schema itself, are ignored. Empty (default) ignores none. include_owner: Emit ALTER ... OWNER TO statements to reconcile ownership. Off by default: ownership references cluster-level roles that routinely differ across environments, so it is not part of the default convergence. @@ -91,6 +100,7 @@ def generate( target=target, index_concurrently=index_concurrently, ignore_extension_version=ignore_extension_version, + ignore_schemas=ignore_schemas, include_owner=include_owner, include_grants=include_grants, ) diff --git a/src/pgmig/_cli.py b/src/pgmig/_cli.py index c8ba2a5..53c9607 100644 --- a/src/pgmig/_cli.py +++ b/src/pgmig/_cli.py @@ -83,6 +83,14 @@ def generate( help="Do not emit ALTER EXTENSION ... UPDATE TO for this extension's version mismatch (repeatable).", ), ] = None, + ignore_schema: Annotated[ + list[str] | None, + typer.Option( + "--ignore-schema", + help="Exclude this schema from the diff entirely -- its objects and the schema " + "create/drop are ignored (repeatable).", + ), + ] = None, include_owner: Annotated[ bool, typer.Option( @@ -112,6 +120,7 @@ def generate( target=target, index_concurrently=index_concurrently, ignore_extension_version=ignore_extension_version or [], + ignore_schemas=ignore_schema or [], include_owner=include_owner, include_grants=include_grants, ) diff --git a/src/pgmig/_introspect/_context.py b/src/pgmig/_introspect/_context.py index 076f153..498d521 100644 --- a/src/pgmig/_introspect/_context.py +++ b/src/pgmig/_introspect/_context.py @@ -19,6 +19,9 @@ class _ContextData: # Result being assembled by the loaders. db_introspection_result: DbIntrospectionResult + # Schemas to exclude from the diff entirely. + ignore_schemas: frozenset[str] + # Context of the current introspection. _context: ContextVar[_ContextData] = ContextVar("pgmig_introspection_context") @@ -35,11 +38,13 @@ def context_scope( *, conn: DbReadOnlyConnection, db_introspection_result: DbIntrospectionResult, + ignore_schemas: frozenset[str], ) -> Iterator[None]: token = _context.set( _ContextData( conn=conn, db_introspection_result=db_introspection_result, + ignore_schemas=ignore_schemas, ) ) try: @@ -55,5 +60,9 @@ def conn(self) -> DbReadOnlyConnection: def db_introspection_result(self) -> DbIntrospectionResult: return _context.get().db_introspection_result + @property + def ignore_schemas(self) -> frozenset[str]: + return _context.get().ignore_schemas + context = _Context() diff --git a/src/pgmig/_introspect/_core.py b/src/pgmig/_introspect/_core.py index c45ae6a..b033e3c 100644 --- a/src/pgmig/_introspect/_core.py +++ b/src/pgmig/_introspect/_core.py @@ -48,6 +48,7 @@ class IntrospectionQuery(Enum): UNSUPPORTED = auto() INVALID_INDEXES = auto() MATVIEW_DEPENDENCIES_CHECK = auto() + SCHEMA_CONNECTIONS = auto() # Loaders, in dependency-significant order. SCHEMAS = auto() @@ -84,6 +85,8 @@ def get_introspection_query_config(query: IntrospectionQuery) -> IntrospectionQu return IntrospectionQueryConfig(file_name="invalid_indexes.sql", kind=IntrospectionQueryType.GUARD) case IntrospectionQuery.MATVIEW_DEPENDENCIES_CHECK: return IntrospectionQueryConfig(file_name="matview_dependencies.sql", kind=IntrospectionQueryType.GUARD) + case IntrospectionQuery.SCHEMA_CONNECTIONS: + return IntrospectionQueryConfig(file_name="schema_connections.sql", kind=IntrospectionQueryType.GUARD) case IntrospectionQuery.SCHEMAS: return IntrospectionQueryConfig(file_name="schemas.sql", kind=IntrospectionQueryType.LOAD) case IntrospectionQuery.TABLES: @@ -184,4 +187,23 @@ async def run_introspection_query(query: IntrospectionQuery, model: type[_RowT]) """ Run the given introspection query, parsing each row into the given model. """ - return await context.conn.introspect(_read_query(get_introspection_query_config(query).file_name), model) + # Get the query config. + config = get_introspection_query_config(query) + + # Get the query SQL. + sql = _read_query(config.file_name) + + # Run the query. + rows = await context.conn.introspect(sql, model) + + # Filter out rows in ignored schemas. + ignored_schemas = context.ignore_schemas + if config.kind is IntrospectionQueryType.LOAD: + filtered_rows = [] + for row in rows: + if isinstance(row, IntrospectionRowWithSchema) and row.schema_name in ignored_schemas: + continue + filtered_rows.append(row) + rows = filtered_rows + + return rows diff --git a/src/pgmig/_introspect/_engine.py b/src/pgmig/_introspect/_engine.py index e9029f8..f58fb2d 100644 --- a/src/pgmig/_introspect/_engine.py +++ b/src/pgmig/_introspect/_engine.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + from pgmig._db import DbReadOnlyConnection from pgmig._errors import PgmigUnsupportedError from pgmig._introspect import ( @@ -17,6 +19,7 @@ matview_indexes, policies, range_types, + schema_connections, schemas, sequences, tables, @@ -105,10 +108,11 @@ def get_loaders(self) -> list[Loader]: return loaders -async def introspect_db(dsn: str) -> DbIntrospectionResult: +async def introspect_db(*, dsn: str, ignore_schemas: Sequence[str] = ()) -> DbIntrospectionResult: """ Build the full structure of the given database. """ + # Initialize the introspection result. db_introspection_result = DbIntrospectionResult( schema_by_name={}, extension_by_name={}, @@ -125,12 +129,24 @@ async def introspect_db(dsn: str) -> DbIntrospectionResult: with context.context_scope( conn=conn, db_introspection_result=db_introspection_result, + ignore_schemas=frozenset(ignore_schemas), ): + # Verify that the ignored schemas are isolated from the kept ones. + if ignore_schemas: + connections = await schema_connections.check() + if connections: + message = ( + "pgmig cannot ignore a schema that is connected to a kept schema " + "(the migration would fail at apply):\n" + + "\n".join(f" - {finding}" for finding in connections) + ) + raise PgmigUnsupportedError(message) + # Run the preflight query to find out which introspection steps to run. preflight_result = await run_introspection_query(IntrospectionQuery.PREFLIGHT, _IntrospectionPreflight) preflight = preflight_result[0] - # Look for any unsupported state. + # Look for any unsupported objects. all_findings = [finding for guard in preflight.get_guards() for finding in await guard()] if all_findings: message = "pgmig cannot process this database:\n" + "\n".join( diff --git a/src/pgmig/_introspect/default_privileges.py b/src/pgmig/_introspect/default_privileges.py index 1974938..2b3f9af 100644 --- a/src/pgmig/_introspect/default_privileges.py +++ b/src/pgmig/_introspect/default_privileges.py @@ -22,6 +22,7 @@ class _GrantRow(IntrospectionRow): class _DefaultAclRow(IntrospectionRow): role: str + # None is a cluster-wide rule (not scoped to any schema); such a rule is never ignored. schema_name: str | None object_type: str # defaclobjtype: 'r' / 'S' / 'f' / 'T' / 'n' grants: list[_GrantRow] @@ -37,6 +38,11 @@ async def load() -> None: ALTER DEFAULT PRIVILEGES rules (pg_default_acl, database-level). """ for row in await run_introspection_query(IntrospectionQuery.DEFAULT_PRIVILEGES, _DefaultAclRow): + # schema_name is optional here (None = a cluster-wide rule), so the row is not an + # IntrospectionRowWithSchema and run_introspection_query does not drop it; skip a rule + # scoped to an ignored schema. + if row.schema_name in context.ignore_schemas: + continue key = DefaultAclKey(role=row.role, schema=row.schema_name, object_type=row.object_type) context.db_introspection_result.default_acl_by_key[key] = DefaultAcl( role=row.role, diff --git a/src/pgmig/_introspect/matview_dependencies.py b/src/pgmig/_introspect/matview_dependencies.py index 2c27573..5cc47b9 100644 --- a/src/pgmig/_introspect/matview_dependencies.py +++ b/src/pgmig/_introspect/matview_dependencies.py @@ -28,6 +28,12 @@ async def load() -> None: once that pairing is ruled out. """ for row in await run_introspection_query(IntrospectionQuery.MATVIEW_DEPENDENCIES_LOAD, _MatviewDependencyRow): + # These rows name their schemas dependent_schema/referenced_schema (not the shared + # schema_name), so run_introspection_query does not drop them; skip an edge touching an + # ignored schema so no ignored matview enters the dependency map. A connected schema is + # refused up front, so a surviving ignored edge has both endpoints in the ignored schema. + if row.dependent_schema in context.ignore_schemas or row.referenced_schema in context.ignore_schemas: + continue dependent = RelationKey(row.dependent_schema, row.dependent_view) referenced = RelationKey(row.referenced_schema, row.referenced_view) context.db_introspection_result.matview_dependencies.setdefault(dependent, set()).add(referenced) diff --git a/src/pgmig/_introspect/queries/schema_connections.sql b/src/pgmig/_introspect/queries/schema_connections.sql new file mode 100644 index 0000000..57e029a --- /dev/null +++ b/src/pgmig/_introspect/queries/schema_connections.sql @@ -0,0 +1,123 @@ +-- User dependency edges (pg_depend) that cross a schema boundary: an object in one user schema +-- depending on an object in a different user schema. Used only when --ignore-schema is set, to +-- refuse ignoring a schema that is connected to a kept one. +-- +-- Each endpoint's schema is pg_identify_object's schema, falling back to the owning relation's +-- schema for a schema-less sub-object (a rewrite rule backs a view's reads, and a trigger / +-- column default / policy belongs to a table -- none carry their own schema). One query then +-- covers every dependency kind (foreign keys, view/matview reads via the _RETURN rule, function +-- bodies, cross-schema column types, OWNED BY) without per-kind logic. +-- +-- Only normal ('n') and auto ('a') dependencies are user connections; internal ('i'), extension +-- ('e') and pin ('p') edges are an object's own machinery. System schemas on either side are +-- excluded (a dependency on a built-in type lives in pg_catalog, not a user-schema link). +SELECT + obj.schema AS obj_schema, + obj.identity AS obj_identity, + ref.schema AS ref_schema, + ref.identity AS ref_identity +FROM + pg_depend d + CROSS JOIN LATERAL ( + SELECT + COALESCE(io.schema, owner.nspname) AS schema, + io.identity AS IDENTITY + FROM + pg_identify_object (d.classid, d.objid, d.objsubid) AS io + LEFT JOIN LATERAL ( + SELECT + n.nspname + FROM + pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE + c.oid = CASE d.classid + WHEN 'pg_rewrite'::regclass THEN + ( + SELECT + ev_class + FROM + pg_rewrite + WHERE + oid = d.objid) + WHEN 'pg_trigger'::regclass THEN + ( + SELECT + tgrelid + FROM + pg_trigger + WHERE + oid = d.objid) + WHEN 'pg_attrdef'::regclass THEN + ( + SELECT + adrelid + FROM + pg_attrdef + WHERE + oid = d.objid) + WHEN 'pg_policy'::regclass THEN + ( + SELECT + polrelid + FROM + pg_policy + WHERE + oid = d.objid) + END) AS owner ON TRUE) AS obj + CROSS JOIN LATERAL ( + SELECT + COALESCE(io.schema, owner.nspname) AS schema, + io.identity AS IDENTITY + FROM + pg_identify_object (d.refclassid, d.refobjid, d.refobjsubid) AS io + LEFT JOIN LATERAL ( + SELECT + n.nspname + FROM + pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE + c.oid = CASE d.refclassid + WHEN 'pg_rewrite'::regclass THEN + ( + SELECT + ev_class + FROM + pg_rewrite + WHERE + oid = d.refobjid) + WHEN 'pg_trigger'::regclass THEN + ( + SELECT + tgrelid + FROM + pg_trigger + WHERE + oid = d.refobjid) + WHEN 'pg_attrdef'::regclass THEN + ( + SELECT + adrelid + FROM + pg_attrdef + WHERE + oid = d.refobjid) + WHEN 'pg_policy'::regclass THEN + ( + SELECT + polrelid + FROM + pg_policy + WHERE + oid = d.refobjid) + END) AS owner ON TRUE) AS ref +WHERE + d.deptype IN ('n', 'a') + AND obj.schema IS NOT NULL + AND ref.schema IS NOT NULL + AND obj.schema <> ref.schema + AND obj.schema NOT LIKE 'pg_%' + AND obj.schema <> 'information_schema' + AND ref.schema NOT LIKE 'pg_%' + AND ref.schema <> 'information_schema'; diff --git a/src/pgmig/_introspect/schema_connections.py b/src/pgmig/_introspect/schema_connections.py new file mode 100644 index 0000000..1663b93 --- /dev/null +++ b/src/pgmig/_introspect/schema_connections.py @@ -0,0 +1,30 @@ +from pgmig._introspect._context import context +from pgmig._introspect._core import IntrospectionQuery, IntrospectionRow, run_introspection_query + + +class _ConnectionRow(IntrospectionRow): + obj_schema: str + obj_identity: str + ref_schema: str + ref_identity: str + + +async def check() -> list[str]: + """ + Report dependency edges that connect an ignored schema to a kept one, in either direction. + + Ignoring a schema that is not isolated would emit a migration that fails at apply -- e.g. a + DROP/recreate in a kept schema blocked by an object in the ignored schema that still depends + on it, or a kept object referencing an ignored one pgmig no longer manages. Rather than emit + such a migration (or silently drop the edge), `--ignore-schema` refuses a connected schema. + + Runs only when at least one schema is ignored. A pair with both endpoints ignored is not a + connection to a kept schema, so it is allowed; only an edge with exactly one ignored endpoint + crosses the boundary. + """ + ignore = context.ignore_schemas + findings: set[str] = set() + for row in await run_introspection_query(IntrospectionQuery.SCHEMA_CONNECTIONS, _ConnectionRow): + if (row.obj_schema in ignore) != (row.ref_schema in ignore): + findings.add(f"{row.obj_identity} depends on {row.ref_identity}") + return sorted(findings) diff --git a/tests/_api/generate_setup.py b/tests/_api/generate_setup.py index 452f555..def844a 100644 --- a/tests/_api/generate_setup.py +++ b/tests/_api/generate_setup.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + import pytest from pgmig import PgmigUnsupportedError, agenerate @@ -44,6 +46,7 @@ async def assert_diff( index_concurrently: bool = False, include_owner: bool = False, include_grants: bool = False, + ignore_schemas: Sequence[str] = (), ) -> None: """ Set up both databases, assert the generated migration, then apply and confirm it converges. @@ -57,6 +60,7 @@ async def assert_diff( index_concurrently: Pass through to `generate` to emit CONCURRENTLY index statements. include_owner: Pass through to `generate` to emit ALTER ... OWNER TO statements. include_grants: Pass through to `generate` to emit named-role GRANT / REVOKE. + ignore_schemas: Pass through to `generate` to exclude these schemas from the diff. """ # Shared setup runs on both DBs, before the side-specific statements. src = (both or []) + src @@ -83,6 +87,7 @@ async def assert_diff( index_concurrently=index_concurrently, include_owner=include_owner, include_grants=include_grants, + ignore_schemas=ignore_schemas, ) # Verify the result. @@ -98,6 +103,7 @@ async def assert_diff( index_concurrently=index_concurrently, include_owner=include_owner, include_grants=include_grants, + ignore_schemas=ignore_schemas, ) assert residual == "", f"\nMigration did not make source match target.\nResidual diff:\n{residual}" @@ -108,6 +114,7 @@ async def assert_unsupported( dst: list[str], both: list[str] | None = None, match: str | None = None, + ignore_schemas: Sequence[str] = (), ) -> None: """ Wrapper around `assert_diff` that asserts the migration refuses the change with a @@ -120,4 +127,5 @@ async def assert_unsupported( both=both, diff=[], apply=False, + ignore_schemas=ignore_schemas, ) diff --git a/tests/_api/schema/test_ignore_schema.py b/tests/_api/schema/test_ignore_schema.py new file mode 100644 index 0000000..c99cb55 --- /dev/null +++ b/tests/_api/schema/test_ignore_schema.py @@ -0,0 +1,284 @@ +import pytest + +from pgmig import PgmigUnsupportedError, agenerate +from pgmig._db import UniqueViolation +from tests._api.generate_setup import GenerateSetup +from tests._api.schema.test_extension import _get_installable_extension + +# Every object kind, all inside one schema, with rich within-schema wiring (a table with a +# primary key, check, index, RLS policy and trigger; a sequence, enum, domain, composite type, +# range type, function, view, materialized view + its index, and a schema-scoped default +# privilege). Ignoring the schema must drop all of it -- exercising every loader's filter and +# proving the attach-loaders (index/constraint/trigger/policy/matview-index) don't KeyError when +# their parent was filtered out. +_ALL_KINDS = [ + "CREATE SCHEMA ext", + "CREATE SEQUENCE ext.seq", + "CREATE TYPE ext.mood AS ENUM ('happy', 'sad')", + "CREATE DOMAIN ext.positive AS integer CHECK (VALUE > 0)", + "CREATE TYPE ext.pair AS (a integer, b integer)", + "CREATE TYPE ext.int_range AS RANGE (SUBTYPE = integer)", + "CREATE FUNCTION ext.trig_fn() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN RETURN NEW; END;$$", + "CREATE TABLE ext.t (id integer PRIMARY KEY, m ext.mood, n integer DEFAULT nextval('ext.seq'), CHECK (n >= 0))", + "CREATE INDEX ext_t_n_idx ON ext.t (n)", + "CREATE TRIGGER ext_t_trig BEFORE INSERT ON ext.t FOR EACH ROW EXECUTE FUNCTION ext.trig_fn()", + "ALTER TABLE ext.t ENABLE ROW LEVEL SECURITY", + "CREATE POLICY ext_t_pol ON ext.t USING (true)", + "CREATE VIEW ext.v AS SELECT id FROM ext.t", + "CREATE MATERIALIZED VIEW ext.mv AS SELECT id FROM ext.t", + "CREATE INDEX ext_mv_idx ON ext.mv (id)", + "ALTER DEFAULT PRIVILEGES IN SCHEMA ext GRANT SELECT ON TABLES TO PUBLIC", +] + + +async def test_ignore_schema_suppresses_every_object_kind(gen_setup: GenerateSetup) -> None: + """ + One schema holding every object kind, all present only on the target: ignoring it yields an + empty diff -- nothing in it is loaded, and no attach-loader KeyErrors on a filtered parent. + """ + await gen_setup.assert_diff( + src=[], + dst=_ALL_KINDS, + diff=[], + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_suppresses_extension(gen_setup: GenerateSetup) -> None: + """ + An extension installed into an ignored schema is excluded. + """ + ext = await _get_installable_extension(gen_setup.src) + await gen_setup.assert_diff( + both=["CREATE SCHEMA ext"], + src=[], + dst=[f"CREATE EXTENSION {ext.name} SCHEMA ext"], + diff=[], + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_suppresses_create(gen_setup: GenerateSetup) -> None: + """ + A schema (and its objects) present only on the target is normally created; with + --ignore-schema it is excluded entirely -- no CREATE SCHEMA, no objects inside it. + """ + await gen_setup.assert_diff( + src=[], + dst=["CREATE SCHEMA ext", "CREATE TABLE ext.t (n integer)", "CREATE SEQUENCE ext.s"], + diff=[], + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_suppresses_drop(gen_setup: GenerateSetup) -> None: + """ + A schema present only on the source is normally dropped; with --ignore-schema it is left + alone -- no DROP of the schema or its objects. + """ + await gen_setup.assert_diff( + src=["CREATE SCHEMA ext", "CREATE TABLE ext.t (n integer)"], + dst=[], + diff=[], + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_ignores_object_drift(gen_setup: GenerateSetup) -> None: + """ + Differences in objects inside an ignored schema (present on both sides) produce no diff. + """ + await gen_setup.assert_diff( + both=["CREATE SCHEMA ext"], + src=["CREATE TABLE ext.t (n integer)"], + dst=["CREATE TABLE ext.t (n integer, extra text)", "CREATE VIEW ext.v AS SELECT 1 AS x"], + diff=[], + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_leaves_other_schemas(gen_setup: GenerateSetup) -> None: + """ + Only the named schema is ignored: drift in other schemas is still diffed normally. + """ + await gen_setup.assert_diff( + both=["CREATE SCHEMA ext"], + src=[], + dst=[ + "CREATE TABLE ext.ignored (n integer)", + "CREATE TABLE public.kept (n integer)", + ], + diff=['CREATE TABLE "public"."kept" ("n" integer)'], + ignore_schemas=["ext"], + ) + + +async def test_ignore_multiple_schemas(gen_setup: GenerateSetup) -> None: + """ + Every schema in the list is ignored. + """ + await gen_setup.assert_diff( + src=[], + dst=[ + "CREATE SCHEMA a", + "CREATE TABLE a.t (n integer)", + "CREATE SCHEMA b", + "CREATE TABLE b.t (n integer)", + ], + diff=[], + ignore_schemas=["a", "b"], + ) + + +async def test_ignore_schema_still_guards_unsupported(gen_setup: GenerateSetup) -> None: + """ + An unsupported object (a rule) in an ignored schema still trips the unsupported guard: the + guards are not exempted by --ignore-schema, so pgmig refuses a database it cannot fully + process even when the offending object is in an ignored (but isolated) schema. + """ + await gen_setup.assert_unsupported( + src=[], + dst=[ + "CREATE SCHEMA ext", + "CREATE TABLE ext.t (n integer)", + "CREATE RULE ext_no_insert AS ON INSERT TO ext.t DO INSTEAD NOTHING", + ], + match=r"not supported", + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_still_guards_invalid_index(gen_setup: GenerateSetup) -> None: + """ + An invalid index in an ignored schema still trips the invalid-index guard. + """ + await gen_setup.src.execute("CREATE SCHEMA ext") + await gen_setup.src.execute("CREATE TABLE ext.t (a integer)") + await gen_setup.src.execute("INSERT INTO ext.t VALUES (1), (1)") + with pytest.raises(UniqueViolation): + await gen_setup.src.execute("CREATE UNIQUE INDEX CONCURRENTLY u ON ext.t (a)") + + with pytest.raises(PgmigUnsupportedError, match=r"invalid index"): + await agenerate(source=gen_setup.src.dsn, target=gen_setup.dst.dsn, ignore_schemas=["ext"]) + + +async def test_ignore_schema_excludes_matview_dependency_edges(gen_setup: GenerateSetup) -> None: + """ + A matview reading another matview records a dependency edge in matview_dependencies.load; + when both sit in the ignored schema the edge is dropped, so no ignored matview leaks into the + dependency map (its rows carry dependent_schema/referenced_schema, not the shared filter's + schema_name, so load skips them itself). + """ + await gen_setup.assert_diff( + src=[], + dst=[ + "CREATE SCHEMA ext", + "CREATE MATERIALIZED VIEW ext.base AS SELECT 1 AS x", + "CREATE MATERIALIZED VIEW ext.derived AS SELECT x FROM ext.base", + ], + diff=[], + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_still_guards_matview_dependency(gen_setup: GenerateSetup) -> None: + """ + A plain view reading a materialized view is refused even inside an ignored schema: the + matview-dependency guard is not exempted by --ignore-schema. + """ + await gen_setup.assert_unsupported( + src=[], + dst=[ + "CREATE SCHEMA ext", + "CREATE MATERIALIZED VIEW ext.m AS SELECT 1 AS x", + "CREATE VIEW ext.v AS SELECT x FROM ext.m", + ], + match=r"not supported", + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_refuses_ignored_depends_on_kept(gen_setup: GenerateSetup) -> None: + """ + An object in the ignored schema depending on a kept one (ext.child -> public.k) means the + schema is not isolated -- dropping/recreating public.k would be blocked by ext.child at + apply -- so ignoring it is refused. + """ + await gen_setup.assert_unsupported( + src=[], + dst=[ + "CREATE TABLE public.k (id integer PRIMARY KEY)", + "CREATE SCHEMA ext", + "CREATE TABLE ext.child (id integer REFERENCES public.k (id))", + ], + match=r"connected", + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_refuses_kept_depends_on_ignored(gen_setup: GenerateSetup) -> None: + """ + A kept object depending on one in the ignored schema (public.k -> ext.t) is refused too: + ignoring ext would leave public.k referencing a schema pgmig no longer manages. + """ + await gen_setup.assert_unsupported( + src=[], + dst=[ + "CREATE SCHEMA ext", + "CREATE TABLE ext.t (id integer PRIMARY KEY)", + "CREATE TABLE public.k (id integer REFERENCES ext.t (id))", + ], + match=r"connected", + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_view_across_boundary_refused(gen_setup: GenerateSetup) -> None: + """ + A view read across the boundary is a connection just like a foreign key: a kept view reading + an ignored table is refused. + """ + await gen_setup.assert_unsupported( + src=[], + dst=[ + "CREATE SCHEMA ext", + "CREATE TABLE ext.t (id integer)", + "CREATE VIEW public.v AS SELECT id FROM ext.t", + ], + match=r"connected", + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_connection_between_kept_schemas_allowed(gen_setup: GenerateSetup) -> None: + """ + Only a dependency that touches the ignored schema is refused. A cross-schema link between two + kept schemas (a.t <- b.k) is fine while a third schema is ignored. + """ + await gen_setup.assert_diff( + both=[ + "CREATE SCHEMA a", + "CREATE TABLE a.t (id integer PRIMARY KEY)", + "CREATE SCHEMA b", + "CREATE TABLE b.k (id integer REFERENCES a.t (id))", + ], + src=[], + dst=["CREATE SCHEMA ext", "CREATE TABLE ext.iso (n integer)"], + diff=[], + ignore_schemas=["ext"], + ) + + +async def test_ignore_schema_unset_still_diffs(gen_setup: GenerateSetup) -> None: + """ + Control: without --ignore-schema the same schema is created normally, confirming the tests + above exercise the flag rather than some other exclusion. + """ + await gen_setup.assert_diff( + src=[], + dst=["CREATE SCHEMA ext", "CREATE TABLE ext.t (n integer)"], + diff=[ + 'CREATE SCHEMA "ext"', + 'CREATE TABLE "ext"."t" ("n" integer)', + ], + ) diff --git a/tests/_introspect/test_view_dependency_filters.py b/tests/_introspect/test_view_dependency_filters.py index 19d6ee6..f4791b8 100644 --- a/tests/_introspect/test_view_dependency_filters.py +++ b/tests/_introspect/test_view_dependency_filters.py @@ -13,7 +13,7 @@ async def test_view_dependencies_exclude_system_and_extension_referenced_views(g await gen_setup.src.execute("CREATE VIEW v_sys AS SELECT pid FROM pg_stat_activity") await gen_setup.src.execute("CREATE VIEW v_ext AS SELECT userid FROM pg_stat_statements") - info = await introspect_db(gen_setup.src.dsn) + info = await introspect_db(dsn=gen_setup.src.dsn) # Every referenced view across all recorded edges lives in a managed (non-system) schema. referenced = {ref for refs in info.view_dependencies.values() for ref in refs} diff --git a/tests/test_cli.py b/tests/test_cli.py index 7f7b2f4..7a4aba7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -185,6 +185,15 @@ async def test_ignore_extension_version_flags_pass_list(mocker: MockerFixture) - assert spy.call_args.kwargs["ignore_extension_version"] == ["postgis", "hstore"] +async def test_ignore_schema_flags_pass_list(mocker: MockerFixture) -> None: + spy = mocker.patch("pgmig._cli.generate_migration", return_value="") + + result = await _run_cli("generate -s src -t tgt --ignore-schema audit --ignore-schema staging") + + assert result.exit_code == 0 + assert spy.call_args.kwargs["ignore_schemas"] == ["audit", "staging"] + + async def test_no_ignore_flags_passes_empty_list(mocker: MockerFixture) -> None: spy = mocker.patch("pgmig._cli.generate_migration", return_value="") @@ -192,3 +201,4 @@ async def test_no_ignore_flags_passes_empty_list(mocker: MockerFixture) -> None: assert result.exit_code == 0 assert spy.call_args.kwargs["ignore_extension_version"] == [] + assert spy.call_args.kwargs["ignore_schemas"] == []