Skip to content

Commit aaa444f

Browse files
authored
Merge pull request #10 from bitner/fix/funcchanges
Fix routine diff handling and update docs
2 parents e6a5013 + 8715780 commit aaa444f

9 files changed

Lines changed: 1877 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
- `makemigration` now preserves body-only routine changes by appending `CREATE OR REPLACE` definitions when `results.schemadiff_as_sql()` misses them.
12+
- `makemigration` now filters unsafe routine drops only when the same routine kind and signature still exist in the target schema, avoiding false positives during function-to-procedure transitions.
13+
814
## [0.1.0] - 2026-05-05
915

1016
### Added

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ ordered `sql/` directory. `pgpkg` does everything else:
99
`<prefix>--<version>.sql`.
1010
2. **`makemigration`** — diffs two staged versions with
1111
[results](https://github.com/djrobstep/results) to generate an incremental
12-
migration `<prefix>--A--B.sql`.
12+
migration `<prefix>--A--B.sql`, including body-only function/procedure
13+
changes that require `CREATE OR REPLACE`.
1314
3. **`graph`** — shows the version graph and shortest paths between versions.
1415
4. **`migrate`** — connects to a live database (libpq env vars or psql-style
1516
flags), reads the currently installed version, and applies the shortest

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A short tour of the modules:
1010
| `pgpkg.staging` | render `sql/` into a single string with fragment markers |
1111
| `pgpkg.versioning` | PEP 440 + `unreleased`-last ordering |
1212
| `pgpkg.planner` | BFS over the catalog graph for shortest `source → target` paths |
13-
| `pgpkg.diff` | wrap `results.temporary_local_db` + `schemadiff_as_sql` |
13+
| `pgpkg.diff` | wrap `results.temporary_local_db` + `schemadiff_as_sql`, then patch in body-only routine replacements and safe routine-drop filtering |
1414
| `pgpkg.tracking` | default tracking DDL, version-source protocol, role-safe bookkeeping |
1515
| `pgpkg.executor` | run a plan in one xact with `pg_advisory_xact_lock` |
1616
| `pgpkg._conn` | thin psycopg helper honoring libpq env vars |

docs/cli.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ pgpkg makemigration [--from VERSION] [--to VERSION] [--base-url URL] [--output P
8383
Writes `<prefix>--<from>--<to>.sql`. `--base-url` is the postgres URL used
8484
to spawn tempdbs via `results.temporary_local_db`. Defaults to
8585
`postgresql:///postgres`, i.e. a local admin connection through the peer
86-
socket.
86+
socket. When the raw schema diff misses body-only function or procedure
87+
changes, `pgpkg` appends `CREATE OR REPLACE` definitions from the target
88+
version and avoids unsafe same-kind routine drops.
8789

8890
When wrapper SQL is supplied, `pgpkg` renders the output in this order:
8991

docs/index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ code. You own:
1515

1616
- **stageversion** — renders `sql/` into `<prefix>--<version>.sql`
1717
- **makemigration** — diffs two staged versions into `<prefix>--<from>--<to>.sql`
18-
using `results.temporary_local_db` + `results.schemadiff_as_sql`
18+
using `results.temporary_local_db` + `results.schemadiff_as_sql`, with an
19+
extra routine-definition pass so body-only function/procedure changes are
20+
emitted as `CREATE OR REPLACE`
1921
- **graph** / **plan** — show the version graph and the shortest path to a
2022
target version
2123
- **migrate** — apply the plan inside one transaction with an advisory lock,

src/pgpkg/diff.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
from __future__ import annotations
44

5+
import re
56
from dataclasses import dataclass
67
from pathlib import Path
8+
from typing import Any
79

810
from .config import ProjectConfig
911
from .errors import PgpkgError
@@ -53,9 +55,131 @@ def generate_incremental_sql(
5355
with db_to.t() as t:
5456
t.execute(to_sql)
5557
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"
5667
return diff_sql
5768

5869

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+
59183
def write_incremental(
60184
config: ProjectConfig,
61185
*,

tests/integration/test_diff.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,73 @@ def test_makemigration_wraps_diff_with_files_and_sql(
8080
assert '"sampleext"."extra"' in body
8181
assert "SELECT 42;" in body
8282
assert "SELECT 'done';" in body
83+
84+
85+
def test_makemigration_includes_body_only_function_changes(
86+
staged_project: Path,
87+
pg_url: str,
88+
):
89+
from pgpkg.api import stage_version
90+
91+
# Change only function body/signature internals without adding/removing objects.
92+
(staged_project / "sql" / "020_functions.sql").write_text(
93+
"""CREATE OR REPLACE FUNCTION sampleext.item_count()
94+
RETURNS bigint
95+
LANGUAGE sql
96+
AS $$
97+
SELECT count(*) + 1 FROM sampleext.items;
98+
$$;
99+
"""
100+
)
101+
stage_version(staged_project, "unreleased")
102+
103+
path = generate_incremental(
104+
staged_project,
105+
from_version="0.2.0",
106+
to_version="unreleased",
107+
base_url=pg_url,
108+
)
109+
body = path.read_text().lower()
110+
assert "create or replace function sampleext.item_count()" in body
111+
assert "count(*) + 1" in body
112+
113+
114+
def test_makemigration_includes_body_only_procedure_changes(
115+
staged_project: Path,
116+
pg_url: str,
117+
):
118+
from pgpkg.api import stage_version
119+
120+
(staged_project / "sql" / "020_functions.sql").write_text(
121+
"""CREATE OR REPLACE PROCEDURE sampleext.refresh_items()
122+
LANGUAGE plpgsql
123+
AS $$
124+
BEGIN
125+
PERFORM count(*) FROM sampleext.items;
126+
END;
127+
$$;
128+
"""
129+
)
130+
stage_version(staged_project, "0.2.0")
131+
132+
(staged_project / "sql" / "020_functions.sql").write_text(
133+
"""CREATE OR REPLACE PROCEDURE sampleext.refresh_items()
134+
LANGUAGE plpgsql
135+
AS $$
136+
BEGIN
137+
PERFORM count(*) + 1 FROM sampleext.items;
138+
END;
139+
$$;
140+
"""
141+
)
142+
stage_version(staged_project, "unreleased")
143+
144+
path = generate_incremental(
145+
staged_project,
146+
from_version="0.2.0",
147+
to_version="unreleased",
148+
base_url=pg_url,
149+
)
150+
body = path.read_text().lower()
151+
assert "create or replace procedure sampleext.refresh_items()" in body
152+
assert "count(*) + 1" in body

tests/unit/test_diff.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
from pgpkg.diff import _strip_unsafe_routine_drops
4+
5+
6+
def test_strip_unsafe_routine_drops_keeps_needed_drop() -> None:
7+
diff_sql = "\n".join(
8+
[
9+
'drop function if exists "sampleext"."f"(integer);',
10+
'drop function if exists "sampleext"."g"(text);',
11+
"create table sampleext.t(id int);",
12+
]
13+
)
14+
15+
target_sigs = {("function", "sampleext.f(integer)")}
16+
17+
out = _strip_unsafe_routine_drops(diff_sql, target_sigs)
18+
19+
assert 'drop function if exists "sampleext"."f"(integer);' not in out
20+
assert 'drop function if exists "sampleext"."g"(text);' in out
21+
assert "create table sampleext.t(id int);" in out
22+
23+
24+
def test_strip_unsafe_routine_drops_keeps_drop_when_kind_changes() -> None:
25+
diff_sql = 'drop function if exists "sampleext"."f"(integer);'
26+
27+
target_sigs = {("procedure", "sampleext.f(integer)")}
28+
29+
out = _strip_unsafe_routine_drops(diff_sql, target_sigs)
30+
31+
assert 'drop function if exists "sampleext"."f"(integer);' in out
32+
33+
34+
def test_strip_unsafe_routine_drops_keeps_drop_for_case_sensitive_name() -> None:
35+
diff_sql = 'drop function if exists "sampleext"."camelcase"(integer);'
36+
37+
target_sigs = {("function", "sampleext.CamelCase(integer)")}
38+
39+
out = _strip_unsafe_routine_drops(diff_sql, target_sigs)
40+
41+
assert 'drop function if exists "sampleext"."camelcase"(integer);' in out

0 commit comments

Comments
 (0)