fix: re-emit VALIDATE for an unvalidated PG18 NOT NULL constraint (#564) - #587
Conversation
On PostgreSQL 18 a NOT NULL constraint added NOT VALID already sets attnotnull, so the inspector saw the column as NOT NULL and a re-plan after an interrupted online apply produced nothing, leaving existing rows unchecked forever. Surface the unvalidated constraint name on the column IR and emit VALIDATE CONSTRAINT (in its own transaction) when the desired state is NOT NULL. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The change set is coherent end-to-end (introspection → IR → diff emission → plan isolation) and includes both fixture coverage and a targeted unit test for the critical isolation behavior.
Pull request overview
This PR fixes a PostgreSQL 18+ edge case where a NOT NULL ... NOT VALID constraint can remain unvalidated (e.g., interrupted apply), but the column still appears NOT NULL via attnotnull, causing subsequent plans to incorrectly converge without re-emitting the required VALIDATE CONSTRAINT.
Changes:
- Extend column introspection to detect pending (unvalidated) PG18
contype = 'n'NOT NULL constraints and surface the constraint name in IR. - Treat “desired NOT NULL + current has pending NOT NULL validation” as drift so diffs re-emit
VALIDATE CONSTRAINT <name>. - Ensure the emitted
VALIDATE CONSTRAINTruns in its own execution group (transaction isolation), and update fixtures + add a plan-layer unit test.
File summaries
| File | Description |
|---|---|
| testdata/diff/online/add_not_null/old.sql | Adds the repro scenario: a NOT NULL ... NOT VALID constraint left unvalidated. |
| testdata/diff/online/add_not_null/new.sql | Declares the column as NOT NULL in desired state to force convergence behavior. |
| testdata/diff/online/add_not_null/diff.sql | Expects re-emission of VALIDATE CONSTRAINT users_phone_not_null. |
| testdata/diff/online/add_not_null/plan.sql | Includes the extra VALIDATE CONSTRAINT step in the plan output. |
| testdata/diff/online/add_not_null/plan.txt | Reflects the additional column diff and isolated transaction group. |
| testdata/diff/online/add_not_null/plan.json | Adds a new execution group for the pending validation step. |
| ir/queries/queries.sql | Joins pg_constraint to detect unvalidated PG18 NOT NULL constraints per column. |
| ir/queries/queries.sql.go | Regenerated sqlc output to carry the new invalid_not_null_constraint field. |
| ir/ir.go | Adds Column.InvalidNotNullConstraint to represent pending PG18 validation state (current-state signal). |
| ir/inspector.go | Populates InvalidNotNullConstraint from query results during inspection. |
| internal/diff/column.go | Emits VALIDATE CONSTRAINT when both sides are NOT NULL but current has pending validation; also marks columns unequal in that case. |
| internal/plan/rewrite.go | Forces the pending VALIDATE CONSTRAINT step into its own execution group (isolation). |
| internal/plan/rewrite_test.go | Adds a unit test ensuring the pending validation step is isolated. |
| docs/workflow/online-ddl.mdx | Documents the “interrupted apply / added by hand” scenario and re-validation behavior. |
Review details
Files not reviewed (1)
- ir/queries/queries.sql.go: Generated file
- Files reviewed: 13/14 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Greptile SummaryThis PR restores interrupted PostgreSQL 18 native NOT NULL migrations by exposing pending validation state through inspection, generating the missing
Confidence Score: 5/5The PR appears safe to merge; no actionable correctness, security, compatibility, or repository-rule violations were identified. Catalog query generation, inspector conversion, diff behavior, identifier quoting, and planner transaction isolation are aligned, and the regression coverage exercises both validation emission and execution grouping. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
PG[(PostgreSQL catalogs)] --> Q[Inspect pg_constraint]
Q --> IR[Column pending-validation state]
IR --> D{Desired column is NOT NULL?}
D -->|Yes, validation pending| SQL[Emit VALIDATE CONSTRAINT]
SQL --> G[Isolated transaction group]
G --> A[Apply validation scan]
A --> C[Converged schema]
Reviews (1): Last reviewed commit: "fix: re-emit VALIDATE for an unvalidated..." | Re-trigger Greptile |
Summary
Follow-up to #566, which added the PG18 native
ADD CONSTRAINT ... NOT NULL ... NOT VALID+VALIDATE CONSTRAINTrewrite. That PR noted a gap: if apply is interrupted after the ADD but before the VALIDATE (or someone adds aNOT VALIDNOT NULL constraint by hand), PostgreSQL already setsattnotnull, so the inspector saw the column as NOT NULL and the next plan was empty. Existing rows were never validated and there was no way to notice.This is the "remind on next run" part of #564.
Fix
GetColumns/GetColumnsForSchemanow joinpg_constraintfor acontype = 'n'row withconvalidated = falseon the column and expose it asinvalid_not_null_constraint(empty on PG < 18, where that contype does not exist). sqlc regenerated.Column.InvalidNotNullConstraintIR field, only ever populated on the current state.columnsEqualtreats a NOT NULL desired column with a pending constraint as modified, andgenerateColumnSQLemitsALTER TABLE ... VALIDATE CONSTRAINT <name>;for it.NOT NULL, which is the desired end state, and plan shows the pending VALIDATE.Out of scope (by design, see #564 discussion)
ADD COLUMN ... NOT NULL: without a backfill VALIDATE would still fail, and pgschema cannot infer the backfill. The manual workflow is documented in Custom Migration Steps.Closes #564
Test plan
testdata/diff/online/add_not_null:old.sqladdsphone textplusADD CONSTRAINT users_phone_not_null NOT NULL phone NOT VALID;new.sqldeclaresphone text NOT NULL. Expected diff now includesVALIDATE CONSTRAINT users_phone_not_null, and plan fixtures were regenerated against embedded PG18 (apply + convergence verified).TestPendingNotNullValidateIsolatedininternal/plan/rewrite_test.gochecks the VALIDATE step lands in its own execution group.PGSCHEMA_TEST_FILTER="online/" go test ./internal/diff,go test ./internal/plan,PGSCHEMA_TEST_FILTER="online/add_not_null" go test ./cmd -run TestPlanAndApply,create_table/remove_not_null, andTestDumpCommand_Employee; all pass. Full suite left to CI.🤖 Generated with Claude Code