Skip to content

Commit 1b58fb4

Browse files
authored
Merge pull request #81 from gmr/feature/storage-statistics-rules
Model column attributes, constraint comments, extended statistics and rules
2 parents 0438f04 + a477a26 commit 1b58fb4

34 files changed

Lines changed: 1554 additions & 107 deletions

Cargo.lock

Lines changed: 8 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ indicatif = "0.18.4"
1515
indicatif-log-bridge = "0.2.3"
1616
jsonschema = { version = "0.46.5", default-features = false }
1717
libpgdump = "2.3"
18-
libpgfmt = "1.4"
18+
libpgfmt = "1.5"
1919
log = "0.4"
2020
rpassword = "7"
2121
serde = { version = "1.0.228", features = ["derive"] }
2222
serde_json = { version = "1.0.150", features = ["preserve_order"] }
2323
serde_norway = "0.9.42"
2424
simplelog = "0.12"
2525
tempfile = "3"
26-
tree-sitter = "0.26"
27-
tree-sitter-postgres = "19.0.0-beta.4"
26+
tree-sitter = "0.27"
27+
tree-sitter-postgres = "19.0.0-beta.4.1"
2828

2929
[dev-dependencies]
3030
libpgdump = "2.3"

PLAN-coverage.md

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

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.
3+
Status: Phases 0 to 5 complete, and Phase 6 items 1 to 6. Phase 6
4+
item 7, and Phases 7 and 8, proposed.
55
Written 2026-09-21.
66

77
Every claim below was verified against PostgreSQL 18.4 (the version
@@ -548,22 +548,42 @@ enabled state.
548548
→ verify: pull a database containing all seven; `remaining.yaml` is
549549
absent, and `just round-trip` passes with them in `fixtures/schema.sql`.
550550

551-
### Phase 6 — New object models (~4 days, ranked) — items 1 to 3 **DONE**
551+
### Phase 6 — New object models (~4 days, ranked) — items 1 to 6 **DONE**
552552

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.
553+
**Items 4 to 6 done:** column `COMPRESSION` and `STORAGE`, plus the
554+
statistics target and attribute options, which pg_dump writes the same
555+
way (item 4); extended `STATISTICS` as a top-level object (item 5); and
556+
`RULE` on tables and views, with a view's `_RETURN` rule folded into
557+
its query (item 6). Also comments on primary key, unique, check,
558+
foreign key and NOT NULL constraints, which the unmatched-comment rule
559+
from items 1 to 3 otherwise turned into pull failures.
557560

558-
Where the work departs from the items below:
561+
**Item 7, `SECURITY LABEL`, is deferred.** A label needs a provider
562+
module preloaded into the server, and the stock `postgres:18` image has
563+
none, so no gate could verify it. It needs a CI image built with a
564+
provider (`dummy_seclabel` from the PostgreSQL test modules) first.
565+
566+
Every construct the coverage fixture held is now modeled. It records
567+
the gaps that remain instead: procedures and operators (which build
568+
and the models support, but pull cannot parse), and operator classes,
569+
operator families and access methods (which have no model).
570+
571+
572+
**Done (items 1 to 3, historical note):** `EXCLUDE` constraints
573+
(item 1), `REPLICA IDENTITY` (item 2) and default privileges (item 3),
574+
each pulled, built, deployed and in `fixtures/schema.sql`. When these
575+
items were done, items 4 to 7 were still open; see the status above
576+
for their current state.
577+
578+
Where the work on items 1 to 3 departs from the items below:
559579

560580
- **An unmatched comment now fails the pull.** Pull logged a warning
561581
for a comment it could not attach and dropped it, which the
562582
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.
583+
`remaining.yaml`. At that time the model had a comment for an
584+
exclusion constraint only, so a comment on another constraint type
585+
failed the pull. The `constraint_comments` map on a table (added
586+
with items 4 to 6) now closes that gap.
567587
- **Default privileges are a new top-level object type**,
568588
`default_privileges/<role>.yaml`, not part of `src/build/acls.rs`:
569589
they are keyed by the role the defaults are `FOR`, and the role files

bin/deploy-gates

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ echo "==> Gate 2: convergence (mutate, deploy --apply, re-deploy is empty)"
9898
# row-security drift: turned off and unforced, one policy dropped, one
9999
# edited, one database-only, and a changed policy comment
100100
# replica identity reset and changed, an exclusion constraint's comment
101-
# changed, and another one dropped
101+
# changed, and another one dropped; column storage, compression,
102+
# statistics target and options changed; constraint comments changed
103+
# and cleared, and a commented check replaced by a different one, whose
104+
# comment deploy has to set again after re-adding it; rules
105+
# re-enabled, dropped, commented and replaced, on a table and a view
102106
psql -d "${TARGET_DB}" -q -v ON_ERROR_STOP=1 <<'SQL'
103107
ALTER TABLE test.users ADD COLUMN scratch text;
104108
ALTER TABLE test.users ALTER COLUMN display_name SET NOT NULL;
@@ -121,6 +125,20 @@ ALTER TABLE test.replica_index REPLICA IDENTITY FULL;
121125
COMMENT ON CONSTRAINT room_bookings_no_overlap ON test.room_bookings
122126
IS 'drift';
123127
ALTER TABLE test.handles DROP CONSTRAINT handles_unique_lower;
128+
ALTER TABLE test.documents ALTER COLUMN body SET STORAGE EXTENDED;
129+
ALTER TABLE test.documents ALTER COLUMN body SET COMPRESSION pglz;
130+
ALTER TABLE test.documents ALTER COLUMN id SET STATISTICS 100;
131+
ALTER TABLE test.documents ALTER COLUMN size RESET (n_distinct);
132+
COMMENT ON CONSTRAINT invoices_pkey ON test.invoices IS 'drift';
133+
COMMENT ON CONSTRAINT invoices_number_key ON test.invoices IS NULL;
134+
ALTER TABLE test.invoices DROP CONSTRAINT invoices_total_positive;
135+
ALTER TABLE test.invoices ADD CONSTRAINT invoices_total_positive
136+
CHECK (total > 0);
137+
ALTER TABLE test.ledger ENABLE RULE ledger_redirect;
138+
DROP RULE ledger_no_delete ON test.ledger;
139+
COMMENT ON RULE ledger_audit_insert ON test.ledger IS 'drift';
140+
CREATE OR REPLACE RULE ledger_view_insert AS ON INSERT TO test.ledger_view
141+
DO INSTEAD NOTHING;
124142
SQL
125143
./target/debug/pglifecycle deploy --apply --allow-drop -d "${TARGET_DB}" \
126144
"${WORKDIR}/project"

bin/generate-schema-docs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
'publication',
5656
'schema',
5757
'sequence',
58+
'statistics',
5859
'server',
5960
'subscription',
6061
'table',
@@ -78,6 +79,7 @@
7879
'dependencies',
7980
'foreign_key',
8081
'policy',
82+
'rule',
8183
'trigger',
8284
],
8385
'Per-schema container files': [

docs/commands.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,11 @@ reconciled in place where PostgreSQL can express it:
8282
- **Tables** — add column, set/drop default, set/drop not-null,
8383
add/drop check constraints, foreign keys and exclusion constraints,
8484
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.
85+
`REPLICA IDENTITY`, column storage, compression, statistics target
86+
and options, rules (`CREATE OR REPLACE RULE`, their state and
87+
comment), and comment changes, including comments on constraints. A
88+
changed comment on a constraint is set alone, without rebuilding the
89+
constraint; a constraint that is re-added gets its comment again.
8790
Identity columns are added, and their `ALWAYS`/`BY DEFAULT` behavior
8891
and sequence options changed, with `ALTER COLUMN`; renaming an
8992
identity's sequence falls back. A `NOT VALID` check, foreign key or
@@ -103,7 +106,8 @@ reconciled in place where PostgreSQL can express it:
103106
pglifecycle modeled them, leaves the table's row security as the
104107
database has it.
105108
- **Functions and views**`CREATE OR REPLACE` (a function whose
106-
return type changed must be dropped first, so it falls back).
109+
return type changed must be dropped first, so it falls back). A
110+
view's rules are reconciled after it.
107111
- **Sequences** — a single `ALTER SEQUENCE` of the changed options.
108112
- **Domains** — set/drop default; a base-type or constraint change
109113
falls back.
@@ -133,6 +137,12 @@ any are pending. Index, trigger, and constraint drops issued while
133137
reconciling a table are *not* gated: they lose no data and the project
134138
is authoritative.
135139

140+
`DROP RULE` is gated although a rule holds no data. A `DO INSTEAD
141+
NOTHING` rule can block writes, so dropping one can let through changes
142+
the database refused before. A project pulled with a version of
143+
pglifecycle that did not model rules has none on any table or view,
144+
and the gate stops a deploy of that project from dropping every rule.
145+
136146
`DROP IDENTITY` is gated although it keeps every row: the sequence goes
137147
with it, so adding the identity back restarts the numbering and
138148
collides with existing keys. A project pulled with a version of
@@ -161,16 +171,17 @@ Ownership is not managed (the script behaves like
161171
`pg_restore --no-owner`), and roles, users, groups, and tablespaces are
162172
skipped entirely — they are cluster-level objects a single-database
163173
dump cannot capture. Aggregates, casts, collations, conversions, event
164-
triggers, publications, text search objects and default privileges are
165-
created when
174+
triggers, publications, text search objects, default privileges and
175+
extended statistics are created when
166176
missing but otherwise only existence-checked: `pull` models them, but
167177
`deploy` does not compare their definitions yet, so a changed one is
168178
left as the database has it. An aggregate is matched by its name and
169179
input types, so each overload is checked on its own. Text search
170180
objects are checked per schema: when a schema has any text search
171181
object in the database, `deploy` creates none of the project's text
172182
search objects in that schema. Object types `pull` does not yet model
173-
(operators, rules, extended statistics, …) are handled the same way. Privileges on
183+
(operators, operator classes and families, access methods, …) are
184+
handled the same way. Privileges on
174185
created objects are emitted (unless `-x`); privilege changes on objects
175186
that already exist are not yet diffed.
176187

@@ -216,14 +227,14 @@ went missing.
216227
```console
217228
$ pglifecycle pull ./project -d mydb
218229
...
219-
error: 3 dump entries could not be modeled (RULE, STATISTICS), so the
230+
error: 3 dump entries could not be modeled (OPERATOR, OPERATOR CLASS), so the
220231
generated project would not reproduce the source database.
221232
The entries were preserved in ./project/remaining.yaml; re-run with
222233
--allow-unsupported to accept the project as it is.
223234
```
224235

225236
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.
237+
it, such as a comment on an object type that `pull` does not model.
227238

228239
The project directory is written either way, so `remaining.yaml` is
229240
there to inspect. `--allow-unsupported` downgrades the failure to a

docs/project-format.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,48 @@ exclude_constraints:
183183
replica_identity: FULL
184184
```
185185

186+
- Columns may carry `storage`, `compression`, `statistics` (the
187+
statistics target) and `options` (such as `n_distinct`), which
188+
`build` writes as `ALTER COLUMN ... SET` after `CREATE TABLE`, as
189+
`pg_dump` does. Comments on a table's primary key, unique, check,
190+
foreign key and NOT NULL constraints live in `constraint_comments`,
191+
keyed by constraint name.
192+
193+
- Tables and views carry `rules`. A rule's `commands` are absent for
194+
`DO INSTEAD NOTHING`. A view's internal `_RETURN` rule is its query,
195+
never a rule.
196+
197+
```yaml
198+
rules:
199+
- name: ledger_audit_insert
200+
event: INSERT
201+
condition: (new.amount > (0)::numeric)
202+
commands:
203+
- |-
204+
INSERT INTO test.ledger_audit (id)
205+
VALUES (new.id)
206+
- name: ledger_no_delete
207+
event: DELETE
208+
instead: true
209+
comment: Append only
210+
```
211+
212+
Conditions and commands keep the form `pg_dump` writes them in.
213+
214+
- Extended statistics live in `statistics/<schema>/<name>.yaml`, not
215+
on the table: the name is schema-qualified, and the owner need not
216+
own the table.
217+
218+
```yaml
219+
---
220+
name: measurements_ab
221+
schema: test
222+
owner: postgres
223+
table: test.measurements
224+
kinds: [ndistinct, dependencies]
225+
elements: [a, b]
226+
```
227+
186228
- Default privileges live in `default_privileges/<role>.yaml`, one file
187229
for each role whose new objects they apply to (`ALTER DEFAULT
188230
PRIVILEGES FOR ROLE`). Global and per-schema declarations compose in

fixtures/schema.sql

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,63 @@ ALTER TABLE test.replica_index REPLICA IDENTITY USING INDEX replica_index_id;
525525
ALTER DEFAULT PRIVILEGES IN SCHEMA test GRANT SELECT ON TABLES TO PUBLIC;
526526
ALTER DEFAULT PRIVILEGES IN SCHEMA test GRANT USAGE ON SEQUENCES TO PUBLIC;
527527
ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
528+
529+
-- Column attributes pg_dump writes as ALTER COLUMN after CREATE TABLE:
530+
-- compression, storage, the statistics target and attribute options
531+
CREATE TABLE test.documents (
532+
id INTEGER PRIMARY KEY,
533+
body TEXT COMPRESSION lz4,
534+
blob BYTEA,
535+
size NUMERIC
536+
);
537+
ALTER TABLE test.documents ALTER COLUMN body SET STORAGE EXTERNAL;
538+
ALTER TABLE test.documents ALTER COLUMN blob SET STORAGE MAIN;
539+
ALTER TABLE test.documents ALTER COLUMN id SET STATISTICS 500;
540+
ALTER TABLE test.documents ALTER COLUMN size SET (n_distinct = 100);
541+
542+
-- Comments on each kind of constraint: primary key, unique, check,
543+
-- foreign key, NOT NULL, and a NOT VALID check, whose comment follows
544+
-- its own entry
545+
CREATE TABLE test.invoices (
546+
id INTEGER CONSTRAINT invoices_pkey PRIMARY KEY,
547+
number TEXT NOT NULL UNIQUE,
548+
total NUMERIC CONSTRAINT invoices_total_positive CHECK (total >= 0),
549+
document INTEGER CONSTRAINT invoices_document REFERENCES test.documents (id)
550+
);
551+
ALTER TABLE test.invoices
552+
ADD CONSTRAINT invoices_number_short CHECK (length(number) < 20) NOT VALID;
553+
COMMENT ON CONSTRAINT invoices_pkey ON test.invoices IS 'The invoice id';
554+
COMMENT ON CONSTRAINT invoices_number_key ON test.invoices IS 'One per number';
555+
COMMENT ON CONSTRAINT invoices_total_positive ON test.invoices IS 'No credits';
556+
COMMENT ON CONSTRAINT invoices_document ON test.invoices IS 'Its source';
557+
COMMENT ON CONSTRAINT invoices_number_not_null ON test.invoices IS 'Required';
558+
COMMENT ON CONSTRAINT invoices_number_short ON test.invoices IS 'Legacy rows';
559+
560+
-- Extended statistics: chosen kinds, every kind with a target and a
561+
-- comment, expressions, and one on a materialized view
562+
CREATE TABLE test.measurements (a INTEGER, b INTEGER, label TEXT);
563+
CREATE STATISTICS test.measurements_ab (ndistinct, dependencies)
564+
ON a, b FROM test.measurements;
565+
CREATE STATISTICS test.measurements_all ON a, b, label FROM test.measurements;
566+
ALTER STATISTICS test.measurements_all SET STATISTICS 500;
567+
COMMENT ON STATISTICS test.measurements_all IS 'Every kind';
568+
CREATE STATISTICS test.measurements_expr (mcv)
569+
ON (a + b), lower(label) FROM test.measurements;
570+
CREATE STATISTICS test.user_states_stats ON state, total FROM test.user_states;
571+
572+
-- Rules: DO INSTEAD NOTHING with a comment, a conditional DO ALSO with
573+
-- two commands, a disabled one, and one on a view
574+
CREATE TABLE test.ledger (id INTEGER, amount NUMERIC);
575+
CREATE TABLE test.ledger_audit (id INTEGER);
576+
CREATE RULE ledger_no_delete AS ON DELETE TO test.ledger DO INSTEAD NOTHING;
577+
COMMENT ON RULE ledger_no_delete ON test.ledger IS 'Append only';
578+
CREATE RULE ledger_audit_insert AS ON INSERT TO test.ledger
579+
WHERE new.amount > 0
580+
DO ALSO (INSERT INTO test.ledger_audit VALUES (new.id);
581+
INSERT INTO test.ledger_audit VALUES (- new.id));
582+
CREATE RULE ledger_redirect AS ON UPDATE TO test.ledger
583+
DO INSTEAD UPDATE test.ledger_audit SET id = new.id WHERE ledger_audit.id = old.id;
584+
ALTER TABLE test.ledger DISABLE RULE ledger_redirect;
585+
CREATE VIEW test.ledger_view AS SELECT id, amount FROM test.ledger;
586+
CREATE RULE ledger_view_insert AS ON INSERT TO test.ledger_view
587+
DO INSTEAD INSERT INTO test.ledger (id, amount) VALUES (new.id, new.amount);

fixtures/unsupported-descs.txt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
COMMENT unsupported.CONSTRAINT payloads_pkey ON payloads
2-
RULE unsupported.append_only append_only_no_delete
3-
STATISTICS unsupported.measurements_stats
4-
TABLE unsupported.payloads
1+
ACCESS METHOD pglifecycle_heap
2+
OPERATOR unsupported.===
3+
OPERATOR CLASS unsupported.int_class
4+
OPERATOR FAMILY unsupported.int_family
5+
PROCEDURE unsupported.archive(integer)

0 commit comments

Comments
 (0)