Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/workflow/online-ddl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;

The constraint name matches what PostgreSQL generates for a `NOT NULL` column in `CREATE TABLE`, so the migrated table converges with a freshly created one.

If the `VALIDATE` step never ran (an interrupted apply, or a `NOT VALID` constraint added by hand), the column already reads as `NOT NULL` in the catalog, but existing rows are unchecked. pgschema detects the unvalidated constraint on the next `plan` and emits just the `VALIDATE CONSTRAINT` step, so the migration can be finished by re-applying.

On PostgreSQL 14-17, adding `NOT NULL` uses a check constraint based process:

```sql
Expand Down
12 changes: 12 additions & 0 deletions internal/diff/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ func (cd *ColumnDiff) generateColumnSQL(tableSchema, tableName string, targetSch
qualifiedTableName, ir.QuoteIdentifier(cd.New.Name))
statements = append(statements, sql)
}
} else if !cd.New.IsNullable && cd.Old.InvalidNotNullConstraint != "" {
// Both sides are NOT NULL, but the current constraint was added NOT VALID
// (PG18+) and never validated, e.g. an interrupted online apply. Finish
// the job so existing rows are actually checked (issue #564).
sql := fmt.Sprintf("ALTER TABLE %s VALIDATE CONSTRAINT %s;",
qualifiedTableName, ir.QuoteIdentifier(cd.Old.InvalidNotNullConstraint))
statements = append(statements, sql)
}

// Handle default value changes
Expand Down Expand Up @@ -173,6 +180,11 @@ func columnsEqual(old, new *ir.Column, targetSchema string) bool {
if old.IsNullable != new.IsNullable {
return false
}
// A NOT NULL constraint that is still NOT VALID must be validated to reach
// the desired NOT NULL state (issue #564).
if !new.IsNullable && old.InvalidNotNullConstraint != "" {
return false
}

// Compare default values (already normalized by ir.normalizeColumn)
if (old.DefaultValue == nil) != (new.DefaultValue == nil) {
Expand Down
14 changes: 14 additions & 0 deletions internal/plan/rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreat
}
}
}
// A pending VALIDATE CONSTRAINT for a NOT VALID NOT NULL constraint
// (issue #564) must run in its own transaction like every other
// VALIDATE step, so the scan is not batched with lock-taking DDL.
if !columnDiff.New.IsNullable && columnDiff.Old.InvalidNotNullConstraint != "" {
for _, stmt := range d.Statements {
if strings.Contains(stmt.SQL, "VALIDATE CONSTRAINT") {
return []RewriteStep{{
SQL: stmt.SQL,
CanRunInTransaction: true,
RequiresIsolation: true,
}}
}
}
}
// Check if identity is being added or changed on an existing column
// This includes: adding identity, or changing identity generation (drop + re-add)
if columnDiff.New.Identity != nil {
Expand Down
34 changes: 34 additions & 0 deletions internal/plan/rewrite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"
"testing"

"github.com/pgplex/pgschema/internal/diff"
"github.com/pgplex/pgschema/ir"
)

Expand Down Expand Up @@ -181,3 +182,36 @@ func TestGenerateColumnNotNullRewriteNameCollision(t *testing.T) {
}
})
}

// TestPendingNotNullValidateIsolated verifies that a VALIDATE CONSTRAINT step
// emitted for a NOT NULL constraint that was added NOT VALID and never
// validated (issue #564) runs in its own execution group, like every other
// VALIDATE step, rather than being batched with surrounding DDL.
func TestPendingNotNullValidateIsolated(t *testing.T) {
colDiff := &diff.ColumnDiff{
Old: &ir.Column{Name: "phone", DataType: "text", IsNullable: false, InvalidNotNullConstraint: "users_phone_not_null"},
New: &ir.Column{Name: "phone", DataType: "text", IsNullable: false},
}
validate := diff.Diff{
Type: diff.DiffTypeTableColumn,
Operation: diff.DiffOperationAlter,
Path: "public.users.phone",
Source: colDiff,
Statements: []diff.SQLStatement{{SQL: "ALTER TABLE users VALIDATE CONSTRAINT users_phone_not_null;"}},
}
other := diff.Diff{
Type: diff.DiffTypeTableColumn,
Operation: diff.DiffOperationAlter,
Path: "public.users.email",
Source: &diff.ColumnDiff{Old: &ir.Column{Name: "email"}, New: &ir.Column{Name: "email"}},
Statements: []diff.SQLStatement{{SQL: "ALTER TABLE users ALTER COLUMN email SET DEFAULT '';"}},
}

groups := groupDiffs([]diff.Diff{other, validate}, 18, nil)
if len(groups) != 2 {
t.Fatalf("got %d groups, want 2 (VALIDATE must be isolated): %+v", len(groups), groups)
}
if got := groups[1].Steps[0].SQL; got != validate.Statements[0].SQL {
t.Errorf("isolated step SQL = %q, want %q", got, validate.Statements[0].SQL)
}
}
3 changes: 3 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ func (i *Inspector) buildColumns(ctx context.Context, schema *IR, targetSchema s
IsNullable: i.safeInterfaceToString(col.IsNullable) == "YES",
Comment: comment,
}
if col.InvalidNotNullConstraint.Valid {
column.InvalidNotNullConstraint = col.InvalidNotNullConstraint.String
}

// Handle generated columns first (attgenerated: 's' = STORED, 'v' = VIRTUAL in PG18+)
attgenerated := i.safeInterfaceToString(col.Attgenerated)
Expand Down
7 changes: 7 additions & 0 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ type Column struct {
GeneratedExpr *string `json:"generated_expr,omitempty"` // Expression for generated columns
IsGenerated bool `json:"is_generated,omitempty"` // True if this is a generated column
GeneratedKind string `json:"generated_kind,omitempty"` // "s" for STORED, "v" for VIRTUAL (PG18+)
// InvalidNotNullConstraint is the name of a NOT NULL constraint on this
// column that was added NOT VALID and has not been validated yet (PG18+).
// The column already reads as NOT NULL (attnotnull is set), so without this
// the pending VALIDATE CONSTRAINT would be invisible to the diff (issue #564).
// Only ever set on the current state; a freshly created desired state has no
// invalid constraints.
InvalidNotNullConstraint string `json:"invalid_not_null_constraint,omitempty"`
// IsSerial is true when the column was created with the SERIAL shorthand:
// its default is nextval() on a sequence that is owned by this column
// (pg_depend) and that carries PostgreSQL's default <table>_<column>_seq
Expand Down
16 changes: 16 additions & 0 deletions ir/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ WITH column_base AS (
c.numeric_scale,
c.udt_name,
COALESCE(d.description, '') AS column_comment,
-- Name of a NOT NULL constraint on this column that was added NOT VALID
-- and never validated (PostgreSQL 18+; contype 'n' does not exist before
-- that, so the join simply yields ''). attnotnull is already set for such
-- a column, so this is the only signal that VALIDATE CONSTRAINT is still
-- pending (issue #564).
COALESCE(nn.conname, '') AS invalid_not_null_constraint,
CASE
WHEN dt.typtype = 'd' THEN
quote_ident(dn.nspname) || '.' || quote_ident(dt.typname)
Expand Down Expand Up @@ -118,6 +124,7 @@ WITH column_base AS (
LEFT JOIN pg_namespace dn ON dt.typnamespace = dn.oid
LEFT JOIN pg_type et ON dt.typelem = et.oid
LEFT JOIN pg_namespace en ON et.typnamespace = en.oid
LEFT JOIN pg_constraint nn ON nn.conrelid = cl.oid AND nn.contype = 'n' AND NOT nn.convalidated AND a.attnum = ANY(nn.conkey)
WHERE
c.table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast')
AND c.table_schema NOT LIKE 'pg_temp_%'
Expand All @@ -137,6 +144,7 @@ SELECT
cb.numeric_scale,
cb.udt_name,
cb.column_comment,
cb.invalid_not_null_constraint,
cb.resolved_type,
cb.is_identity,
cb.identity_generation,
Expand Down Expand Up @@ -186,6 +194,12 @@ WITH column_base AS (
c.numeric_scale,
c.udt_name,
COALESCE(d.description, '') AS column_comment,
-- Name of a NOT NULL constraint on this column that was added NOT VALID
-- and never validated (PostgreSQL 18+; contype 'n' does not exist before
-- that, so the join simply yields ''). attnotnull is already set for such
-- a column, so this is the only signal that VALIDATE CONSTRAINT is still
-- pending (issue #564).
COALESCE(nn.conname, '') AS invalid_not_null_constraint,
CASE
WHEN dt.typtype = 'd' THEN
quote_ident(dn.nspname) || '.' || quote_ident(dt.typname)
Expand Down Expand Up @@ -233,6 +247,7 @@ WITH column_base AS (
LEFT JOIN pg_namespace dn ON dt.typnamespace = dn.oid
LEFT JOIN pg_type et ON dt.typelem = et.oid
LEFT JOIN pg_namespace en ON et.typnamespace = en.oid
LEFT JOIN pg_constraint nn ON nn.conrelid = cl.oid AND nn.contype = 'n' AND NOT nn.convalidated AND a.attnum = ANY(nn.conkey)
WHERE
c.table_schema = $1
)
Expand All @@ -250,6 +265,7 @@ SELECT
cb.numeric_scale,
cb.udt_name,
cb.column_comment,
cb.invalid_not_null_constraint,
cb.resolved_type,
cb.is_identity,
cb.identity_generation,
Expand Down
108 changes: 64 additions & 44 deletions ir/queries/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions testdata/diff/online/add_not_null/diff.sql
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users VALIDATE CONSTRAINT users_phone_not_null;
3 changes: 2 additions & 1 deletion testdata/diff/online/add_not_null/new.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ CREATE TABLE public.users (
id integer NOT NULL,
username text NOT NULL,
email text NOT NULL,
phone text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
);
Loading