Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions linters/cspell/words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ indkey
refclassid
refobjid
refobjsubid
objsubid
regclass
conkey
conname
Expand Down
12 changes: 11 additions & 1 deletion src/pgmig/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -26,14 +27,19 @@ 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.
include_grants: Also emit named-role GRANT / REVOKE. PUBLIC grants are always diffed;
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(
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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,
)
Expand Down
9 changes: 9 additions & 0 deletions src/pgmig/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
Expand Down
9 changes: 9 additions & 0 deletions src/pgmig/_introspect/_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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:
Expand All @@ -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()
24 changes: 23 additions & 1 deletion src/pgmig/_introspect/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
20 changes: 18 additions & 2 deletions src/pgmig/_introspect/_engine.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections.abc import Sequence

from pgmig._db import DbReadOnlyConnection
from pgmig._errors import PgmigUnsupportedError
from pgmig._introspect import (
Expand All @@ -17,6 +19,7 @@
matview_indexes,
policies,
range_types,
schema_connections,
schemas,
sequences,
tables,
Expand Down Expand Up @@ -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={},
Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/pgmig/_introspect/default_privileges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/pgmig/_introspect/matview_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
123 changes: 123 additions & 0 deletions src/pgmig/_introspect/queries/schema_connections.sql
Original file line number Diff line number Diff line change
@@ -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';
Loading