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
5 changes: 5 additions & 0 deletions internal/diff/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment on lines +124 to +127
Copy link

Choose a reason for hiding this comment

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

Missing uniqueness guard on NULLS NOT DISTINCT emission

The clause is emitted unconditionally for any index with NullsNotDistinct = true, but PostgreSQL only permits NULLS NOT DISTINCT on unique indexes. If an IR JSON file is loaded where a non-unique index has "nulls_not_distinct": true (e.g., a manually-edited dump), the generated DDL would be syntactically invalid and fail on apply.

Adding a uniqueness check makes the generation code self-defending:

Suggested change
// NULLS NOT DISTINCT for unique indexes (PostgreSQL 15+)
if index.NullsNotDistinct {
builder.WriteString(" NULLS NOT DISTINCT")
}
// NULLS NOT DISTINCT for unique indexes (PostgreSQL 15+)
if index.NullsNotDistinct && index.Type == ir.IndexTypeUnique {
builder.WriteString(" NULLS NOT DISTINCT")
}

The same guard should be applied in internal/plan/rewrite.go line 414:

if index.NullsNotDistinct && index.Type == ir.IndexTypeUnique {
    sql.WriteString(" NULLS NOT DISTINCT")
}


// WHERE clause for partial indexes
if index.IsPartial && index.Where != "" {
builder.WriteString(" WHERE ")
Expand Down
1 change: 1 addition & 0 deletions internal/diff/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions internal/plan/rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions ir/inspector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +760 to +762
}

// 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
Expand Down
9 changes: 5 additions & 4 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Comment on lines +253 to +257
}

// IndexColumn represents a column within an index
Expand Down
2 changes: 2 additions & 0 deletions testdata/diff/create_index/add_index/diff.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions testdata/diff/create_index/add_index/new.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions testdata/diff/create_index/add_index/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions testdata/diff/create_index/add_index/plan.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions testdata/diff/create_index/add_index/plan.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions testutil/skip_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Comment on lines +64 to 76

Expand Down Expand Up @@ -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)
}
}
}
Loading