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
7 changes: 7 additions & 0 deletions cmd/dump/dump_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ func TestDumpCommand_Issue396CheckConstraintIsNotNull(t *testing.T) {
runExactMatchTest(t, "issue_396_check_constraint_is_not_null")
}

func TestDumpCommand_Issue412UniqueNullsNotDistinct(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
runExactMatchTest(t, "issue_412_unique_nulls_not_distinct")
}

func TestDumpCommand_Issue191FunctionProcedureOverload(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
Expand Down
9 changes: 8 additions & 1 deletion internal/diff/constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ func generateConstraintSQL(constraint *ir.Constraint, targetSchema string) strin
if constraint.IsTemporal && len(cols) > 0 {
cols[len(cols)-1] = cols[len(cols)-1] + " WITHOUT OVERLAPS"
}
return fmt.Sprintf("CONSTRAINT %s UNIQUE (%s)", ir.QuoteIdentifier(constraint.Name), strings.Join(cols, ", "))
modifier := ""
if constraint.NullsNotDistinct {
modifier = " NULLS NOT DISTINCT"
}
return fmt.Sprintf("CONSTRAINT %s UNIQUE%s (%s)", ir.QuoteIdentifier(constraint.Name), modifier, strings.Join(cols, ", "))
case ir.ConstraintTypeForeignKey:
// Always include CONSTRAINT name to preserve explicit FK names
// Use QualifyEntityNameWithQuotes to add schema qualifier when referencing tables in other schemas
Expand Down Expand Up @@ -176,6 +180,9 @@ func constraintsEqual(old, new *ir.Constraint) bool {
if old.IsTemporal != new.IsTemporal {
return false
}
if old.NullsNotDistinct != new.NullsNotDistinct {
return false
}

// Validation status - only compare for CHECK and FOREIGN KEY constraints
// PRIMARY KEY and UNIQUE constraints are always valid (IsValid is not meaningful for them)
Expand Down
22 changes: 17 additions & 5 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,11 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
case ir.ConstraintTypePrimaryKey:
inlineConstraint = fmt.Sprintf(" CONSTRAINT %s PRIMARY KEY", ir.QuoteIdentifier(constraint.Name))
case ir.ConstraintTypeUnique:
inlineConstraint = fmt.Sprintf(" CONSTRAINT %s UNIQUE", ir.QuoteIdentifier(constraint.Name))
modifier := ""
if constraint.NullsNotDistinct {
modifier = " NULLS NOT DISTINCT"
}
inlineConstraint = fmt.Sprintf(" CONSTRAINT %s UNIQUE%s", ir.QuoteIdentifier(constraint.Name), modifier)
case ir.ConstraintTypeForeignKey:
// For FK, use the generateForeignKeyClause with inline=true
fkClause := generateForeignKeyClause(constraint, targetSchema, true)
Expand Down Expand Up @@ -876,8 +880,12 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
columnNames[len(columnNames)-1] = columnNames[len(columnNames)-1] + " WITHOUT OVERLAPS"
}
tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)
sql := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s UNIQUE (%s);",
tableName, ir.QuoteIdentifier(constraint.Name), strings.Join(columnNames, ", "))
modifier := ""
if constraint.NullsNotDistinct {
modifier = " NULLS NOT DISTINCT"
}
sql := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s UNIQUE%s (%s);",
tableName, ir.QuoteIdentifier(constraint.Name), modifier, strings.Join(columnNames, ", "))

context := &diffContext{
Type: DiffTypeTableConstraint,
Expand Down Expand Up @@ -1004,8 +1012,12 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
if constraint.IsTemporal && len(columnNames) > 0 {
columnNames[len(columnNames)-1] = columnNames[len(columnNames)-1] + " WITHOUT OVERLAPS"
}
addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s UNIQUE (%s);",
tableName, ir.QuoteIdentifier(constraint.Name), strings.Join(columnNames, ", "))
modifier := ""
if constraint.NullsNotDistinct {
modifier = " NULLS NOT DISTINCT"
}
addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s UNIQUE%s (%s);",
tableName, ir.QuoteIdentifier(constraint.Name), modifier, strings.Join(columnNames, ", "))

case ir.ConstraintTypeCheck:
// Add CHECK constraint with ensured outer parentheses
Expand Down
15 changes: 8 additions & 7 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,13 +486,14 @@ func (i *Inspector) buildConstraints(ctx context.Context, schema *IR, targetSche
}

c = &Constraint{
Schema: schemaName,
Table: tableName,
Name: constraintName,
Type: cType,
Columns: []*ConstraintColumn{},
NoInherit: constraint.NoInherit,
IsTemporal: constraint.IsPeriod.Bool, // PG18 temporal constraint (WITHOUT OVERLAPS / PERIOD)
Schema: schemaName,
Table: tableName,
Name: constraintName,
Type: cType,
Columns: []*ConstraintColumn{},
NoInherit: constraint.NoInherit,
IsTemporal: constraint.IsPeriod.Bool, // PG18 temporal constraint (WITHOUT OVERLAPS / PERIOD)
NullsNotDistinct: constraint.NullsNotDistinct.Bool, // PG15+ UNIQUE NULLS NOT DISTINCT
}

// Handle foreign key references
Expand Down
5 changes: 3 additions & 2 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,9 @@ type Constraint struct {
Deferrable bool `json:"deferrable,omitempty"`
InitiallyDeferred bool `json:"initially_deferred,omitempty"`
IsValid bool `json:"is_valid,omitempty"`
NoInherit bool `json:"no_inherit,omitempty"` // CHECK constraint NO INHERIT modifier
IsTemporal bool `json:"is_temporal,omitempty"` // PG18: temporal constraint (WITHOUT OVERLAPS on PK/UNIQUE, PERIOD on FK)
NoInherit bool `json:"no_inherit,omitempty"` // CHECK constraint NO INHERIT modifier
IsTemporal bool `json:"is_temporal,omitempty"` // PG18: temporal constraint (WITHOUT OVERLAPS on PK/UNIQUE, PERIOD on FK)
NullsNotDistinct bool `json:"nulls_not_distinct,omitempty"` // PG15+: UNIQUE constraint treats NULLs as not distinct
Comment string `json:"comment,omitempty"`
}

Expand Down
6 changes: 5 additions & 1 deletion ir/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -930,14 +930,18 @@ SELECT
c.condeferred AS initially_deferred,
c.convalidated AS is_valid,
COALESCE((to_jsonb(c) ->> 'conperiod')::boolean, false) AS is_period,
c.connoinherit AS no_inherit
c.connoinherit AS no_inherit,
-- pg_index.indnullsnotdistinct is PG15+. Use to_jsonb so the column reference
-- doesn't fail to plan on PG14 (where the attribute does not exist on pg_index).
COALESCE((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false) AS nulls_not_distinct
FROM pg_constraint c
JOIN pg_class cl ON c.conrelid = cl.oid
JOIN pg_namespace n ON cl.relnamespace = n.oid
LEFT JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
LEFT JOIN pg_class fcl ON c.confrelid = fcl.oid
LEFT JOIN pg_namespace fn ON fcl.relnamespace = fn.oid
LEFT JOIN pg_attribute fa ON fa.attrelid = c.confrelid AND fa.attnum = c.confkey[array_position(c.conkey, a.attnum)]
LEFT JOIN pg_index i ON i.indexrelid = c.conindid
WHERE n.nspname = $1
ORDER BY n.nspname, cl.relname, c.contype, c.conname, a.attnum;

Expand Down
8 changes: 7 additions & 1 deletion ir/queries/queries.sql.go

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE pgschema_repro_nulls
ADD CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Regression for: UNIQUE NULLS NOT DISTINCT modifier dropped from constraints
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing dump test for NULLS NOT DISTINCT

The PR description says pgschema dump was the primary regression surface, yet no dump test (testdata/dump/) is added to verify that the dump command now emits NULLS NOT DISTINCT correctly when inspecting a live database. The diff test covers the planning path, but a dump test (raw.sql + pgschema.sql pair) would directly confirm the inspector → formatter round-trip and prevent regressions on the original reported bug.

-- Add a table-level UNIQUE constraint with the NULLS NOT DISTINCT modifier
-- (PostgreSQL 15+). Without the fix, the inspector loses the modifier and the
-- generated migration emits a plain UNIQUE (a, b) — silently changing the
-- semantics of the constraint and breaking INSERT ... ON CONFLICT flows that
-- rely on NULLs colliding.
CREATE TABLE public.pgschema_repro_nulls (
a integer,
b integer,
CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Regression for: UNIQUE NULLS NOT DISTINCT modifier dropped from constraints
-- The starting state has no UNIQUE constraint at all; we add one in new.sql.
CREATE TABLE public.pgschema_repro_nulls (
a integer,
b integer
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"version": "1.0.0",
"pgschema_version": "1.9.0",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "70a4465367d5d40d0149eadc73c423f9eb954838b6602f00ca3496b264baf2e9"
},
"groups": [
{
"steps": [
{
"sql": "ALTER TABLE pgschema_repro_nulls\nADD CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b);",
"type": "table.constraint",
"operation": "create",
"path": "public.pgschema_repro_nulls.pgschema_repro_nulls_uniq"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE pgschema_repro_nulls
ADD CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b);
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Plan: 1 to modify.

Summary by type:
tables: 1 to modify

Tables:
~ pgschema_repro_nulls
+ pgschema_repro_nulls_uniq (constraint)

DDL to be executed:
--------------------------------------------------

ALTER TABLE pgschema_repro_nulls
ADD CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "issue_412_unique_nulls_not_distinct",
"description": "pgschema dump silently drops the NULLS NOT DISTINCT modifier from UNIQUE table constraints",
"source": "https://github.com/pgplex/pgschema/issues/412",
"notes": [
"NULLS NOT DISTINCT was introduced in PostgreSQL 15; the underlying pg_index.indnullsnotdistinct column does not exist on PG14.",
"The inspector exposes the modifier on UNIQUE constraints by joining pg_index via conindid and reading indnullsnotdistinct defensively (to_jsonb), which collapses to false on PG14."
]
}
32 changes: 32 additions & 0 deletions testdata/dump/issue_412_unique_nulls_not_distinct/pgdump.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
--
-- PostgreSQL database dump
--

SET statement_timeout = 0;
SET lock_timeout = 0;
-- SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SET check_function_bodies = false;
SET client_min_messages = warning;
SET row_security = off;

--
-- Name: pgschema_repro_nulls; Type: TABLE; Schema: public; Owner: -
--

CREATE TABLE public.pgschema_repro_nulls (
a integer,
b integer
);

--
-- Name: pgschema_repro_nulls pgschema_repro_nulls_uniq; Type: CONSTRAINT; Schema: public; Owner: -
--

ALTER TABLE ONLY public.pgschema_repro_nulls
ADD CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b);

--
-- PostgreSQL database dump complete
--
18 changes: 18 additions & 0 deletions testdata/dump/issue_412_unique_nulls_not_distinct/pgschema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
--
-- pgschema database dump
--

-- Dumped from database version PostgreSQL 18.0
-- Dumped by pgschema version 1.9.0


--
-- Name: pgschema_repro_nulls; Type: TABLE; Schema: -; Owner: -
--

CREATE TABLE IF NOT EXISTS pgschema_repro_nulls (
a integer,
b integer,
CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b)
);

14 changes: 14 additions & 0 deletions testdata/dump/issue_412_unique_nulls_not_distinct/raw.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--
-- Test case for GitHub issue #412: UNIQUE NULLS NOT DISTINCT dropped from dump
--
-- The NULLS NOT DISTINCT modifier (PostgreSQL 15+) makes NULL-bearing tuples
-- collide for uniqueness purposes, which is the opposite of the SQL default.
-- pgschema dump used to silently drop the modifier, emitting a plain
-- UNIQUE (...) constraint and quietly changing semantics.
--

CREATE TABLE pgschema_repro_nulls (
a integer,
b integer,
CONSTRAINT pgschema_repro_nulls_uniq UNIQUE NULLS NOT DISTINCT (a, b)
);
2 changes: 2 additions & 0 deletions testutil/skip_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ var skipListRequiresExtension = []string{
// These tests use features not available in PostgreSQL 14 (e.g., NULLS NOT DISTINCT is PG15+).
var skipListPG14 = []string{
"create_index/add_index",
"create_table/add_unique_constraint_nulls_not_distinct",
"TestDumpCommand_Issue412UniqueNullsNotDistinct",
}

// skipListPG14_17 defines test cases that should be skipped for PostgreSQL 14-17.
Expand Down