diff --git a/internal/diff/index.go b/internal/diff/index.go index 27ca0a27..330875b3 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 && index.Type == ir.IndexTypeUnique { + 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..276f0728 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 && index.Type == ir.IndexTypeUnique { + 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..22442d6a 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(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); diff --git a/testutil/skip_list.go b/testutil/skip_list.go index b1cb9e96..1933b8dd 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(append([]string(nil), skipListPG14_15...), skipListPG14...), 15: skipListPG14_15, } @@ -101,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) } } }