|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import re |
5 | 6 | from dataclasses import dataclass |
6 | 7 | from pathlib import Path |
| 8 | +from typing import Any |
7 | 9 |
|
8 | 10 | from .config import ProjectConfig |
9 | 11 | from .errors import PgpkgError |
@@ -53,9 +55,131 @@ def generate_incremental_sql( |
53 | 55 | with db_to.t() as t: |
54 | 56 | t.execute(to_sql) |
55 | 57 | diff_sql: str = db_from.schemadiff_as_sql(db_to) |
| 58 | + target_routine_sigs = _load_routine_signatures(db_to, config.prefix) |
| 59 | + diff_sql = _strip_unsafe_routine_drops(diff_sql, target_routine_sigs) |
| 60 | + routine_diff_sql = _changed_routine_replacements_sql(db_from, db_to, config.prefix) |
| 61 | + |
| 62 | + if routine_diff_sql.strip(): |
| 63 | + if diff_sql.strip(): |
| 64 | + diff_sql = f"{diff_sql.rstrip()}\n\n{routine_diff_sql.rstrip()}\n" |
| 65 | + else: |
| 66 | + diff_sql = f"{routine_diff_sql.rstrip()}\n" |
56 | 67 | return diff_sql |
57 | 68 |
|
58 | 69 |
|
| 70 | +def _changed_routine_replacements_sql(db_from: Any, db_to: Any, schema: str) -> str: |
| 71 | + """Return CREATE OR REPLACE SQL for routines whose bodies changed. |
| 72 | +
|
| 73 | + `results.schemadiff_as_sql()` can miss body-only routine changes. Detect |
| 74 | + those by comparing `pg_get_functiondef` between the staged `from` and `to` |
| 75 | + databases and emit replacement definitions from `to`. |
| 76 | + """ |
| 77 | + from_map = _load_routine_definitions(db_from, schema) |
| 78 | + to_map = _load_routine_definitions(db_to, schema) |
| 79 | + |
| 80 | + replacements: list[str] = [] |
| 81 | + for key, to_def in sorted(to_map.items()): |
| 82 | + from_def = from_map.get(key) |
| 83 | + if from_def is None: |
| 84 | + continue |
| 85 | + if _normalize_sql(from_def) == _normalize_sql(to_def): |
| 86 | + continue |
| 87 | + replacements.append(f"{to_def.rstrip()}\n;") |
| 88 | + return "\n\n".join(replacements) |
| 89 | + |
| 90 | + |
| 91 | +def _load_routine_definitions(db: Any, schema: str) -> dict[tuple[str, str, str, str], str]: |
| 92 | + schema_lit = schema.replace("'", "''") |
| 93 | + sql = f""" |
| 94 | + SELECT |
| 95 | + n.nspname, |
| 96 | + p.prokind, |
| 97 | + p.proname, |
| 98 | + pg_get_function_identity_arguments(p.oid) AS identity_args, |
| 99 | + pg_get_functiondef(p.oid) AS definition |
| 100 | + FROM pg_proc p |
| 101 | + JOIN pg_namespace n ON n.oid = p.pronamespace |
| 102 | + WHERE n.nspname = '{schema_lit}' |
| 103 | + AND p.prokind IN ('f', 'p') |
| 104 | + """ |
| 105 | + with db.t() as t: |
| 106 | + rows = t.execute(sql) |
| 107 | + |
| 108 | + out: dict[tuple[str, str, str, str], str] = {} |
| 109 | + for nspname, prokind, proname, identity_args, definition in rows: |
| 110 | + key = (str(nspname), str(prokind), str(proname), str(identity_args)) |
| 111 | + out[key] = str(definition) |
| 112 | + return out |
| 113 | + |
| 114 | + |
| 115 | +def _load_routine_signatures(db: Any, schema: str) -> set[tuple[str, str]]: |
| 116 | + schema_lit = schema.replace("'", "''") |
| 117 | + sql = f""" |
| 118 | + SELECT |
| 119 | + CASE p.prokind |
| 120 | + WHEN 'p' THEN 'procedure' |
| 121 | + ELSE 'function' |
| 122 | + END, |
| 123 | + n.nspname || '.' || p.proname || '(' || oidvectortypes(p.proargtypes) || ')' |
| 124 | + FROM pg_proc p |
| 125 | + JOIN pg_namespace n ON n.oid = p.pronamespace |
| 126 | + WHERE n.nspname = '{schema_lit}' |
| 127 | + AND p.prokind IN ('f', 'p') |
| 128 | + """ |
| 129 | + with db.t() as t: |
| 130 | + rows = t.execute(sql) |
| 131 | + return { |
| 132 | + (_normalize_routine_kind(str(kind)), _normalize_signature_text(str(sig))) |
| 133 | + for kind, sig in rows |
| 134 | + } |
| 135 | + |
| 136 | + |
| 137 | +def _strip_unsafe_routine_drops(diff_sql: str, target_signatures: set[tuple[str, str]]) -> str: |
| 138 | + """Remove DROP FUNCTION/PROCEDURE statements for routines still in target. |
| 139 | +
|
| 140 | + Some diffs emit a DROP for a routine that is still present in the target |
| 141 | + schema (typically a body-only or property-only change). That can fail when |
| 142 | + other objects depend on the routine; `CREATE OR REPLACE` is the safe path. |
| 143 | + """ |
| 144 | + if not diff_sql.strip(): |
| 145 | + return diff_sql |
| 146 | + |
| 147 | + out_lines: list[str] = [] |
| 148 | + drop_re = re.compile( |
| 149 | + r"^\s*drop\s+(function|procedure)\s+if\s+exists\s+(.+?)\s*;\s*$", |
| 150 | + re.IGNORECASE, |
| 151 | + ) |
| 152 | + |
| 153 | + for line in diff_sql.splitlines(): |
| 154 | + m = drop_re.match(line) |
| 155 | + if m: |
| 156 | + target_key = ( |
| 157 | + _normalize_routine_kind(m.group(1)), |
| 158 | + _normalize_signature_text(m.group(2)), |
| 159 | + ) |
| 160 | + if target_key in target_signatures: |
| 161 | + continue |
| 162 | + out_lines.append(line) |
| 163 | + return "\n".join(out_lines) |
| 164 | + |
| 165 | + |
| 166 | +def _normalize_routine_kind(kind: str) -> str: |
| 167 | + return kind.strip().lower() |
| 168 | + |
| 169 | + |
| 170 | +def _normalize_signature_text(sig: str) -> str: |
| 171 | + s = sig.replace('"', "").strip() |
| 172 | + s = re.sub(r"\s*,\s*", ",", s) |
| 173 | + s = re.sub(r"\(\s*", "(", s) |
| 174 | + s = re.sub(r"\s*\)", ")", s) |
| 175 | + s = re.sub(r"\s+", " ", s) |
| 176 | + return s |
| 177 | + |
| 178 | + |
| 179 | +def _normalize_sql(sql: str) -> str: |
| 180 | + return "\n".join(line.rstrip() for line in sql.strip().splitlines()) |
| 181 | + |
| 182 | + |
59 | 183 | def write_incremental( |
60 | 184 | config: ProjectConfig, |
61 | 185 | *, |
|
0 commit comments