Skip to content

Commit 0438f04

Browse files
authored
Merge pull request #79 from gmr/feature/new-object-models
Model EXCLUDE constraints, replica identity and default privileges
2 parents 50d3437 + c56ed2d commit 0438f04

27 files changed

Lines changed: 1474 additions & 69 deletions

PLAN-coverage.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Coverage plan: silent schema loss, RLS, and PostgreSQL 18
22

3-
Status: Phases 0 to 5 complete. Phases 6-8 proposed.
3+
Status: Phases 0 to 5 complete, and Phase 6 items 1 to 3. The rest of
4+
Phase 6, and Phases 7 and 8, proposed.
45
Written 2026-09-21.
56

67
Every claim below was verified against PostgreSQL 18.4 (the version
@@ -547,7 +548,37 @@ enabled state.
547548
→ verify: pull a database containing all seven; `remaining.yaml` is
548549
absent, and `just round-trip` passes with them in `fixtures/schema.sql`.
549550

550-
### Phase 6 — New object models (~4 days, ranked)
551+
### Phase 6 — New object models (~4 days, ranked) — items 1 to 3 **DONE**
552+
553+
**Done:** `EXCLUDE` constraints (item 1), `REPLICA IDENTITY` (item 2)
554+
and default privileges (item 3), each pulled, built, deployed and in
555+
`fixtures/schema.sql`. The coverage gate list is down from 5 entries
556+
to 4. Items 4 to 7 are still open.
557+
558+
Where the work departs from the items below:
559+
560+
- **An unmatched comment now fails the pull.** Pull logged a warning
561+
for a comment it could not attach and dropped it, which the
562+
governing invariant forbids. It now keeps the entry in
563+
`remaining.yaml`. The model has a comment for an exclusion
564+
constraint only, so a comment on a primary key, unique, check or
565+
foreign key constraint now fails the pull, and the coverage fixture
566+
records that gap.
567+
- **Default privileges are a new top-level object type**,
568+
`default_privileges/<role>.yaml`, not part of `src/build/acls.rs`:
569+
they are keyed by the role the defaults are `FOR`, and the role files
570+
are not written for a pull from `--dump`. Deploy matches them by
571+
existence, like the Phase 5 types. `REVOKE GRANT OPTION FOR` does not
572+
parse into the model; pg_dump folds a grant and a revoke of its
573+
option into a plain grant, so the fixture could not produce one.
574+
- **`USING INDEX` is written with its index.** pg_dump puts `REPLICA
575+
IDENTITY USING INDEX` in the index's entry, and build does the same,
576+
since the index has to exist first.
577+
- **Deviation 27.** The shared index column renderer now writes
578+
`COLLATE`, where the Python wrote `COLLATION`, and quotes column
579+
names.
580+
581+
The original items follow.
551582

552583
Do now, in this order:
553584

bin/deploy-gates

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ echo "==> Gate 2: convergence (mutate, deploy --apply, re-deploy is empty)"
9797
# NOT NULL there outright
9898
# row-security drift: turned off and unforced, one policy dropped, one
9999
# edited, one database-only, and a changed policy comment
100+
# replica identity reset and changed, an exclusion constraint's comment
101+
# changed, and another one dropped
100102
psql -d "${TARGET_DB}" -q -v ON_ERROR_STOP=1 <<'SQL'
101103
ALTER TABLE test.users ADD COLUMN scratch text;
102104
ALTER TABLE test.users ALTER COLUMN display_name SET NOT NULL;
@@ -114,6 +116,11 @@ ALTER POLICY tenant_notes_own ON test.tenant_notes USING (true);
114116
CREATE POLICY stray ON test.tenant_notes AS RESTRICTIVE USING (true);
115117
COMMENT ON POLICY tenant_notes_own ON test.tenant_notes IS 'drift';
116118
ALTER TABLE test.sealed_notes DISABLE ROW LEVEL SECURITY;
119+
ALTER TABLE test.replica_full REPLICA IDENTITY DEFAULT;
120+
ALTER TABLE test.replica_index REPLICA IDENTITY FULL;
121+
COMMENT ON CONSTRAINT room_bookings_no_overlap ON test.room_bookings
122+
IS 'drift';
123+
ALTER TABLE test.handles DROP CONSTRAINT handles_unique_lower;
117124
SQL
118125
./target/debug/pglifecycle deploy --apply --allow-drop -d "${TARGET_DB}" \
119126
"${WORKDIR}/project"

bin/generate-schema-docs.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@
6363
'type',
6464
'view',
6565
],
66-
'Roles and privileges': ['acls', 'group', 'role', 'user', 'user_mapping'],
66+
'Roles and privileges': [
67+
'acls',
68+
'default_privileges',
69+
'group',
70+
'role',
71+
'user',
72+
'user_mapping',
73+
],
6774
'Shared definitions': [
6875
'argument',
6976
'column',

docs/commands.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,10 @@ Objects that differ between the project and the database are
8080
reconciled in place where PostgreSQL can express it:
8181

8282
- **Tables** — add column, set/drop default, set/drop not-null,
83-
add/drop check constraints and foreign keys, primary-key and unique
84-
additions, index and trigger create/drop, and comment changes.
83+
add/drop check constraints, foreign keys and exclusion constraints,
84+
primary-key and unique additions, index and trigger create/drop,
85+
`REPLICA IDENTITY`, and comment changes. A changed comment on an
86+
exclusion constraint is set alone, without rebuilding its index.
8587
Identity columns are added, and their `ALWAYS`/`BY DEFAULT` behavior
8688
and sequence options changed, with `ALTER COLUMN`; renaming an
8789
identity's sequence falls back. A `NOT VALID` check, foreign key or
@@ -159,7 +161,8 @@ Ownership is not managed (the script behaves like
159161
`pg_restore --no-owner`), and roles, users, groups, and tablespaces are
160162
skipped entirely — they are cluster-level objects a single-database
161163
dump cannot capture. Aggregates, casts, collations, conversions, event
162-
triggers, publications and text search objects are created when
164+
triggers, publications, text search objects and default privileges are
165+
created when
163166
missing but otherwise only existence-checked: `pull` models them, but
164167
`deploy` does not compare their definitions yet, so a changed one is
165168
left as the database has it. An aggregate is matched by its name and
@@ -219,6 +222,9 @@ The entries were preserved in ./project/remaining.yaml; re-run with
219222
--allow-unsupported to accept the project as it is.
220223
```
221224

225+
A `COMMENT` entry counts as unmodeled when the model has no place for
226+
it, such as a comment on a primary key or foreign key constraint.
227+
222228
The project directory is written either way, so `remaining.yaml` is
223229
there to inspect. `--allow-unsupported` downgrades the failure to a
224230
warning for the cases where an incomplete project is what you want.

docs/project-format.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,45 @@ policies:
164164
managed: `deploy` leaves its row security and policies as the
165165
database has them. Pull the project again to record them.
166166

167+
- An exclusion constraint lists each element as an index column (a
168+
`name` or an `expression`, with its `collation`, `opclass`,
169+
`direction` and `null_placement`) and the `operator` two rows are
170+
compared with. `replica_identity` is `FULL`, `NOTHING`, or
171+
`{index: name}`; absent is `DEFAULT`, the primary key.
172+
173+
```yaml
174+
exclude_constraints:
175+
- name: room_bookings_no_overlap
176+
method: gist
177+
elements:
178+
- name: room
179+
operator: =
180+
- name: during
181+
operator: '&&'
182+
where: (status <> 'cancelled'::text)
183+
replica_identity: FULL
184+
```
185+
186+
- Default privileges live in `default_privileges/<role>.yaml`, one file
187+
for each role whose new objects they apply to (`ALTER DEFAULT
188+
PRIVILEGES FOR ROLE`). Global and per-schema declarations compose in
189+
PostgreSQL, so each is kept as written; `build` emits the
190+
revocations first, then the grants, as `pg_dump` does.
191+
192+
```yaml
193+
---
194+
name: app_owner
195+
grants:
196+
- schema: reporting
197+
object_type: TABLES
198+
grantee: analyst
199+
privileges: [SELECT]
200+
revocations:
201+
- object_type: FUNCTIONS
202+
grantee: PUBLIC
203+
privileges: [EXECUTE]
204+
```
205+
167206
- A cast has no schema of its own. `pull` files it in the
168207
`casts/<schema>.yaml` of the first schema its function or types name,
169208
or `public` when they are all built-in.

fixtures/schema.sql

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,44 @@ ALTER EVENT TRIGGER pglifecycle_drops DISABLE;
484484
CREATE EVENT TRIGGER pglifecycle_replica ON ddl_command_end
485485
EXECUTE FUNCTION test.note_ddl();
486486
ALTER EVENT TRIGGER pglifecycle_replica ENABLE REPLICA;
487+
488+
-- Exclusion constraints: gist over a range, with a predicate and a
489+
-- comment, and btree over an expression with its operator class and
490+
-- order, INCLUDE and deferral. btree_gist, created above, provides =
491+
-- for integers in gist.
492+
493+
CREATE TABLE test.room_bookings (
494+
room INTEGER,
495+
during TSRANGE,
496+
status TEXT,
497+
CONSTRAINT room_bookings_no_overlap
498+
EXCLUDE USING gist (room WITH =, during WITH &&)
499+
WHERE (status <> 'cancelled')
500+
);
501+
COMMENT ON CONSTRAINT room_bookings_no_overlap ON test.room_bookings IS
502+
'One booking per room at a time';
503+
504+
CREATE TABLE test.handles (
505+
handle TEXT,
506+
owner INTEGER,
507+
CONSTRAINT handles_unique_lower
508+
EXCLUDE USING btree (lower(handle) text_pattern_ops DESC NULLS LAST WITH =)
509+
INCLUDE (owner) DEFERRABLE INITIALLY DEFERRED
510+
);
511+
512+
-- Replica identity: the whole row, none, and a unique index. pg_dump
513+
-- writes the index form in the index's own entry.
514+
CREATE TABLE test.replica_full (id INTEGER);
515+
ALTER TABLE test.replica_full REPLICA IDENTITY FULL;
516+
CREATE TABLE test.replica_nothing (id INTEGER);
517+
ALTER TABLE test.replica_nothing REPLICA IDENTITY NOTHING;
518+
CREATE TABLE test.replica_index (id INTEGER NOT NULL);
519+
CREATE UNIQUE INDEX replica_index_id ON test.replica_index (id);
520+
ALTER TABLE test.replica_index REPLICA IDENTITY USING INDEX replica_index_id;
521+
522+
-- Default privileges, global and per schema. PUBLIC as the grantee
523+
-- keeps the fixture free of a cluster-wide role. These come last, so
524+
-- the objects above are created under the built-in defaults.
525+
ALTER DEFAULT PRIVILEGES IN SCHEMA test GRANT SELECT ON TABLES TO PUBLIC;
526+
ALTER DEFAULT PRIVILEGES IN SCHEMA test GRANT USAGE ON SEQUENCES TO PUBLIC;
527+
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;

fixtures/unsupported-descs.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
CONSTRAINT unsupported.reservations reservations_no_overlap
2-
DEFAULT ACL unsupported.DEFAULT PRIVILEGES FOR TABLES
1+
COMMENT unsupported.CONSTRAINT payloads_pkey ON payloads
32
RULE unsupported.append_only append_only_no_delete
43
STATISTICS unsupported.measurements_stats
54
TABLE unsupported.payloads

fixtures/unsupported.sql

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,21 @@
1616
-- and no provider ships with the standard server, so the statement
1717
-- fails on the gate's cluster.
1818

19-
CREATE EXTENSION btree_gist;
20-
2119
CREATE SCHEMA unsupported;
2220
SET search_path = unsupported, public, pg_catalog;
2321

2422
CREATE ROLE pglifecycle_coverage_reader;
2523

26-
-- Exclusion constraint
27-
CREATE TABLE reservations (
28-
room INT,
29-
during DATERANGE,
30-
CONSTRAINT reservations_no_overlap
31-
EXCLUDE USING gist (room WITH =, during WITH &&)
32-
);
33-
34-
-- Column storage and compression, and the table's replica identity
24+
-- Column storage and compression
3525
CREATE TABLE payloads (
3626
id INT PRIMARY KEY,
3727
body TEXT COMPRESSION lz4
3828
);
3929
ALTER TABLE payloads ALTER COLUMN body SET STORAGE EXTERNAL;
40-
ALTER TABLE payloads REPLICA IDENTITY FULL;
30+
31+
-- A comment on a primary key, unique, check or foreign key constraint:
32+
-- only an exclusion constraint carries one in the model
33+
COMMENT ON CONSTRAINT payloads_pkey ON payloads IS 'The payload id';
4134

4235
-- Extended statistics
4336
CREATE TABLE measurements (a INT, b INT);
@@ -48,7 +41,3 @@ CREATE STATISTICS measurements_stats (ndistinct, dependencies)
4841
CREATE TABLE append_only (id INT);
4942
CREATE RULE append_only_no_delete AS
5043
ON DELETE TO append_only DO INSTEAD NOTHING;
51-
52-
-- Default privileges
53-
ALTER DEFAULT PRIVILEGES IN SCHEMA unsupported
54-
GRANT SELECT ON TABLES TO pglifecycle_coverage_reader;

schemata/default_privileges.yml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
$schema: https://json-schema.org/draft/2020-12/schema
3+
$id: https://gmr.github.io/pglifecycle/schemata/default_privileges.json
4+
title: Default Privileges
5+
description: >
6+
The privileges objects a role creates start with (ALTER DEFAULT
7+
PRIVILEGES FOR ROLE). Global and per-schema declarations compose, so
8+
they are kept as written.
9+
$defs:
10+
declaration:
11+
type: object
12+
properties:
13+
schema:
14+
title: Schema
15+
description: The schema the declaration is limited to; absent for every schema
16+
type: string
17+
object_type:
18+
title: Object Type
19+
enum: [TABLES, SEQUENCES, FUNCTIONS, ROUTINES, TYPES, SCHEMAS, LARGE OBJECTS]
20+
grantee:
21+
title: Grantee
22+
description: The role given or denied the privileges, or PUBLIC
23+
type: string
24+
privileges:
25+
title: Privileges
26+
type: array
27+
items:
28+
type: string
29+
minItems: 1
30+
with_grant_option:
31+
title: With Grant Option
32+
description: PostgreSQL does not give grant options to PUBLIC
33+
type: boolean
34+
required: [object_type, grantee, privileges]
35+
additionalProperties: false
36+
not:
37+
properties:
38+
grantee:
39+
pattern: '^[Pp][Uu][Bb][Ll][Ii][Cc]$'
40+
with_grant_option:
41+
const: true
42+
required: [grantee, with_grant_option]
43+
type: object
44+
properties:
45+
name:
46+
title: Role
47+
description: The role whose new objects the defaults apply to
48+
type: string
49+
grants:
50+
title: Grants
51+
type: array
52+
items:
53+
$ref: '#/$defs/declaration'
54+
revocations:
55+
title: Revocations
56+
description: >
57+
Privileges taken away from the built-in defaults, such as EXECUTE on
58+
functions from PUBLIC. REVOKE GRANT OPTION FOR is not supported, so
59+
a revocation cannot set with_grant_option to true.
60+
type: array
61+
items:
62+
$ref: '#/$defs/declaration'
63+
not:
64+
properties:
65+
with_grant_option:
66+
const: true
67+
required: [with_grant_option]
68+
required: [name]
69+
additionalProperties: false

0 commit comments

Comments
 (0)