From d2ef09c759b8c626cab3fe8262569714ba672497 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 13 Mar 2026 00:23:50 -0700 Subject: [PATCH 1/4] fix: support NULLS NOT DISTINCT on unique indexes (#355) Add support for PostgreSQL 15+ NULLS NOT DISTINCT clause on unique indexes. Previously, this clause was silently dropped during schema inspection and plan generation. Co-Authored-By: Claude Opus 4.6 --- internal/diff/index.go | 5 +++++ internal/diff/table.go | 1 + internal/plan/rewrite.go | 4 ++++ ir/inspector.go | 5 +++++ ir/ir.go | 9 +++++---- testdata/diff/create_index/add_index/diff.sql | 2 ++ testdata/diff/create_index/add_index/new.sql | 2 ++ testdata/diff/create_index/add_index/plan.json | 6 ++++++ testdata/diff/create_index/add_index/plan.sql | 2 ++ testdata/diff/create_index/add_index/plan.txt | 3 +++ 10 files changed, 35 insertions(+), 4 deletions(-) diff --git a/internal/diff/index.go b/internal/diff/index.go index 27ca0a27..ea0135ea 100644 --- a/internal/diff/index.go +++ b/internal/diff/index.go @@ -121,6 +121,11 @@ func generateIndexSQLWithName(index *ir.Index, indexName string, targetSchema st } builder.WriteString(")") + // NULLS NOT DISTINCT for unique indexes (PostgreSQL 15+) + if index.NullsNotDistinct { + builder.WriteString(" NULLS NOT DISTINCT") + } + // WHERE clause for partial indexes if index.IsPartial && index.Where != "" { builder.WriteString(" WHERE ") diff --git a/internal/diff/table.go b/internal/diff/table.go index 1a549501..99ed16c5 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -1414,6 +1414,7 @@ func indexesStructurallyEqual(oldIndex, newIndex *ir.Index) bool { oldIndex.Method != newIndex.Method || oldIndex.IsPartial != newIndex.IsPartial || oldIndex.IsExpression != newIndex.IsExpression || + oldIndex.NullsNotDistinct != newIndex.NullsNotDistinct || oldIndex.Where != newIndex.Where { return false } diff --git a/internal/plan/rewrite.go b/internal/plan/rewrite.go index 28b47277..d88726b7 100644 --- a/internal/plan/rewrite.go +++ b/internal/plan/rewrite.go @@ -411,6 +411,10 @@ func generateIndexSQL(index *ir.Index, isConcurrent bool) string { sql.WriteString(joinStrings(columnParts, ", ")) sql.WriteString(")") + if index.NullsNotDistinct { + sql.WriteString(" NULLS NOT DISTINCT") + } + if index.Where != "" { sql.WriteString(" WHERE ") sql.WriteString(index.Where) diff --git a/ir/inspector.go b/ir/inspector.go index 6d707e73..74011c3f 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -757,6 +757,11 @@ func (i *Inspector) buildIndexes(ctx context.Context, schema *IR, targetSchema s Columns: []*IndexColumn{}, } + // Check for NULLS NOT DISTINCT (PostgreSQL 15+) + if indexRow.Indexdef.Valid && strings.Contains(strings.ToUpper(indexRow.Indexdef.String), "NULLS NOT DISTINCT") { + index.NullsNotDistinct = true + } + // Set WHERE clause for partial indexes if isPartial && indexRow.PartialPredicate.Valid { // Use the predicate as-is from pg_get_expr, which already has proper formatting diff --git a/ir/ir.go b/ir/ir.go index 0909a215..7e418a6a 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -250,10 +250,11 @@ type Index struct { Type IndexType `json:"type"` Method string `json:"method"` // btree, hash, gin, gist, etc. Columns []*IndexColumn `json:"columns"` - IsPartial bool `json:"is_partial"` // has a WHERE clause - IsExpression bool `json:"is_expression"` // functional/expression index - Where string `json:"where,omitempty"` // partial index condition - Comment string `json:"comment,omitempty"` + IsPartial bool `json:"is_partial"` // has a WHERE clause + IsExpression bool `json:"is_expression"` // functional/expression index + Where string `json:"where,omitempty"` // partial index condition + NullsNotDistinct bool `json:"nulls_not_distinct,omitempty"` // NULLS NOT DISTINCT (PG15+) + Comment string `json:"comment,omitempty"` } // IndexColumn represents a column within an index diff --git a/testdata/diff/create_index/add_index/diff.sql b/testdata/diff/create_index/add_index/diff.sql index 6582e861..fbfb4851 100644 --- a/testdata/diff/create_index/add_index/diff.sql +++ b/testdata/diff/create_index/add_index/diff.sql @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS users ( CREATE INDEX IF NOT EXISTS idx_users_email ON users (email varchar_pattern_ops); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (email) NULLS NOT DISTINCT; + CREATE INDEX IF NOT EXISTS idx_users_id ON users (id); CREATE INDEX IF NOT EXISTS idx_users_name ON users (name); diff --git a/testdata/diff/create_index/add_index/new.sql b/testdata/diff/create_index/add_index/new.sql index 17d8e8f6..466264db 100644 --- a/testdata/diff/create_index/add_index/new.sql +++ b/testdata/diff/create_index/add_index/new.sql @@ -10,3 +10,5 @@ CREATE INDEX idx_users_email ON public.users (email varchar_pattern_ops); CREATE INDEX idx_users_id ON public.users (id); -- Test index name with dots (issue #196) CREATE INDEX "public.idx_users" ON public.users (email, name); +-- Test NULLS NOT DISTINCT (issue #355) +CREATE UNIQUE INDEX idx_users_email_unique ON public.users (email) NULLS NOT DISTINCT; diff --git a/testdata/diff/create_index/add_index/plan.json b/testdata/diff/create_index/add_index/plan.json index d663f869..8f638342 100644 --- a/testdata/diff/create_index/add_index/plan.json +++ b/testdata/diff/create_index/add_index/plan.json @@ -20,6 +20,12 @@ "operation": "create", "path": "public.users.idx_users_email" }, + { + "sql": "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (email) NULLS NOT DISTINCT;", + "type": "table.index", + "operation": "create", + "path": "public.users.idx_users_email_unique" + }, { "sql": "CREATE INDEX IF NOT EXISTS idx_users_id ON users (id);", "type": "table.index", diff --git a/testdata/diff/create_index/add_index/plan.sql b/testdata/diff/create_index/add_index/plan.sql index 6582e861..fbfb4851 100644 --- a/testdata/diff/create_index/add_index/plan.sql +++ b/testdata/diff/create_index/add_index/plan.sql @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS users ( CREATE INDEX IF NOT EXISTS idx_users_email ON users (email varchar_pattern_ops); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (email) NULLS NOT DISTINCT; + CREATE INDEX IF NOT EXISTS idx_users_id ON users (id); CREATE INDEX IF NOT EXISTS idx_users_name ON users (name); diff --git a/testdata/diff/create_index/add_index/plan.txt b/testdata/diff/create_index/add_index/plan.txt index 50425870..39873c50 100644 --- a/testdata/diff/create_index/add_index/plan.txt +++ b/testdata/diff/create_index/add_index/plan.txt @@ -6,6 +6,7 @@ Summary by type: Tables: + users + idx_users_email (index) + + idx_users_email_unique (index) + idx_users_id (index) + idx_users_name (index) + public.idx_users (index) @@ -22,6 +23,8 @@ CREATE TABLE IF NOT EXISTS users ( CREATE INDEX IF NOT EXISTS idx_users_email ON users (email varchar_pattern_ops); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (email) NULLS NOT DISTINCT; + CREATE INDEX IF NOT EXISTS idx_users_id ON users (id); CREATE INDEX IF NOT EXISTS idx_users_name ON users (name); From 80c4e92b867d1943bd5a9ca17b41d0233fd7c1b6 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 13 Mar 2026 00:50:04 -0700 Subject: [PATCH 2/4] fix: guard NULLS NOT DISTINCT with IndexTypeUnique check, skip PG14 Address review feedback: - Only emit NULLS NOT DISTINCT when index type is unique (defensive) - Skip create_index/add_index test on PG14 (NULLS NOT DISTINCT is PG15+) Co-Authored-By: Claude Opus 4.6 --- internal/diff/index.go | 2 +- internal/plan/rewrite.go | 2 +- testutil/skip_list.go | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/diff/index.go b/internal/diff/index.go index ea0135ea..330875b3 100644 --- a/internal/diff/index.go +++ b/internal/diff/index.go @@ -122,7 +122,7 @@ func generateIndexSQLWithName(index *ir.Index, indexName string, targetSchema st builder.WriteString(")") // NULLS NOT DISTINCT for unique indexes (PostgreSQL 15+) - if index.NullsNotDistinct { + if index.NullsNotDistinct && index.Type == ir.IndexTypeUnique { builder.WriteString(" NULLS NOT DISTINCT") } diff --git a/internal/plan/rewrite.go b/internal/plan/rewrite.go index d88726b7..276f0728 100644 --- a/internal/plan/rewrite.go +++ b/internal/plan/rewrite.go @@ -411,7 +411,7 @@ func generateIndexSQL(index *ir.Index, isConcurrent bool) string { sql.WriteString(joinStrings(columnParts, ", ")) sql.WriteString(")") - if index.NullsNotDistinct { + if index.NullsNotDistinct && index.Type == ir.IndexTypeUnique { sql.WriteString(" NULLS NOT DISTINCT") } diff --git a/testutil/skip_list.go b/testutil/skip_list.go index b1cb9e96..3cb96e3a 100644 --- a/testutil/skip_list.go +++ b/testutil/skip_list.go @@ -61,9 +61,17 @@ var skipListRequiresExtension = []string{ "create_table/issue_295_pgvector_typmod", } +// skipListPG14 defines test cases that should be skipped for PostgreSQL 14 only. +// +// Reason for skipping: +// These tests use features not available in PostgreSQL 14 (e.g., NULLS NOT DISTINCT is PG15+). +var skipListPG14 = []string{ + "create_index/add_index", +} + // skipListForVersion maps PostgreSQL major versions to their skip lists. var skipListForVersion = map[int][]string{ - 14: skipListPG14_15, + 14: append(skipListPG14_15, skipListPG14...), 15: skipListPG14_15, } From 78872971b84d1d835e52ef67aaa446e7379cfedd Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 13 Mar 2026 01:03:28 -0700 Subject: [PATCH 3/4] fix: remove unnecessary ToUpper, use generic skip message - pg_get_indexdef() always returns uppercase, no need for ToUpper - Use generic skip message for version-based test skips Co-Authored-By: Claude Opus 4.6 --- ir/inspector.go | 2 +- testutil/skip_list.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ir/inspector.go b/ir/inspector.go index 74011c3f..22442d6a 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -758,7 +758,7 @@ func (i *Inspector) buildIndexes(ctx context.Context, schema *IR, targetSchema s } // Check for NULLS NOT DISTINCT (PostgreSQL 15+) - if indexRow.Indexdef.Valid && strings.Contains(strings.ToUpper(indexRow.Indexdef.String), "NULLS NOT DISTINCT") { + if indexRow.Indexdef.Valid && strings.Contains(indexRow.Indexdef.String, "NULLS NOT DISTINCT") { index.NullsNotDistinct = true } diff --git a/testutil/skip_list.go b/testutil/skip_list.go index 3cb96e3a..ecc8761e 100644 --- a/testutil/skip_list.go +++ b/testutil/skip_list.go @@ -109,7 +109,7 @@ func ShouldSkipTest(t *testing.T, testName string, majorVersion int) { patternNormalized := strings.ReplaceAll(pattern, "/", "_") if testName == patternNormalized || testName == pattern { - t.Skipf("Skipping test %q on PostgreSQL %d due to pg_get_viewdef() formatting differences (non-consequential)", testName, majorVersion) + t.Skipf("Skipping test %q on PostgreSQL %d (unsupported feature or formatting differences)", testName, majorVersion) } } } From d11d5d77f5fda9ff92d5bd7586c6232dd1c7bfd4 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 13 Mar 2026 01:11:31 -0700 Subject: [PATCH 4/4] fix: avoid append mutating shared skipListPG14_15 slice Copy skipListPG14_15 before appending PG14-only entries to prevent cross-version bleed-through if the backing array has spare capacity. Co-Authored-By: Claude Opus 4.6 --- testutil/skip_list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testutil/skip_list.go b/testutil/skip_list.go index ecc8761e..1933b8dd 100644 --- a/testutil/skip_list.go +++ b/testutil/skip_list.go @@ -71,7 +71,7 @@ var skipListPG14 = []string{ // skipListForVersion maps PostgreSQL major versions to their skip lists. var skipListForVersion = map[int][]string{ - 14: append(skipListPG14_15, skipListPG14...), + 14: append(append([]string(nil), skipListPG14_15...), skipListPG14...), 15: skipListPG14_15, }