Skip to content

fix: support NULLS NOT DISTINCT on unique indexes (#355)#356

Merged
tianzhou merged 4 commits intomainfrom
fix/issue-355-nulls-not-distinct
Mar 13, 2026
Merged

fix: support NULLS NOT DISTINCT on unique indexes (#355)#356
tianzhou merged 4 commits intomainfrom
fix/issue-355-nulls-not-distinct

Conversation

@tianzhou
Copy link
Contributor

Summary

  • Add NullsNotDistinct field to ir.Index struct to track PostgreSQL 15+ NULLS NOT DISTINCT indexes
  • Detect the clause from pg_get_indexdef() output during inspection (no version-conditional SQL needed)
  • Emit NULLS NOT DISTINCT in both diff and plan rewrite DDL generation paths
  • Include in structural equality comparison so changes trigger index recreation

Fixes #355

Test plan

  • Added NULLS NOT DISTINCT unique index to existing create_index/add_index test case
  • PGSCHEMA_TEST_FILTER="create_index/" go test -v ./internal/diff -run TestDiffFromFiles passes
  • PGSCHEMA_TEST_FILTER="create_index/" go test -v ./cmd -run TestPlanAndApply passes
  • go vet ./... clean

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 13, 2026 07:24
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds support for PostgreSQL 15+ NULLS NOT DISTINCT on unique indexes by tracking the attribute in IR, detecting it during inspection, and emitting it in generated DDL so diffs/plans preserve the clause (fixes #355).

Changes:

  • Added NullsNotDistinct to ir.Index and included it in structural equality.
  • Detected NULLS NOT DISTINCT from pg_get_indexdef() output during inspection.
  • Emitted NULLS NOT DISTINCT in both diff DDL generation and plan rewrite index SQL generation, and updated test fixtures accordingly.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
testdata/diff/create_index/add_index/new.sql Adds a unique index fixture using NULLS NOT DISTINCT.
testdata/diff/create_index/add_index/diff.sql Expected diff SQL now includes NULLS NOT DISTINCT.
testdata/diff/create_index/add_index/plan.sql Expected plan SQL now includes NULLS NOT DISTINCT.
testdata/diff/create_index/add_index/plan.txt Expected human-readable plan output includes the new index.
testdata/diff/create_index/add_index/plan.json Expected JSON plan includes the new index creation SQL.
ir/ir.go Extends Index IR with NullsNotDistinct.
ir/inspector.go Detects NULLS NOT DISTINCT in inspected index definitions.
internal/diff/index.go Emits NULLS NOT DISTINCT in CREATE INDEX DDL generation.
internal/diff/table.go Treats NullsNotDistinct changes as requiring index recreation.
internal/plan/rewrite.go Emits NULLS NOT DISTINCT in plan rewrite index SQL generation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

-- 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;
ir/inspector.go Outdated
Comment on lines +760 to +761
// Check for NULLS NOT DISTINCT (PostgreSQL 15+)
if indexRow.Indexdef.Valid && strings.Contains(strings.ToUpper(indexRow.Indexdef.String), "NULLS NOT DISTINCT") {
@greptile-apps
Copy link

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR adds first-class support for PostgreSQL 15+ NULLS NOT DISTINCT unique indexes across the entire pgschema pipeline: IR struct, database inspection, DDL generation (both diff and plan-rewrite paths), and structural equality comparison.

Key changes:

  • ir.Index gains a NullsNotDistinct bool field (omitempty JSON tag), keeping serialization backward-compatible.
  • The inspector detects the clause from pg_get_indexdef() output with a case-insensitive strings.Contains check — no version-conditional SQL is required since the string simply won't appear on PG14.
  • Both generateIndexSQLWithName (internal/diff/index.go) and generateIndexSQL (internal/plan/rewrite.go) emit NULLS NOT DISTINCT after the column list and before the WHERE clause, which matches the PostgreSQL DDL grammar.
  • indexesStructurallyEqual includes the new field so that toggling NULLS NOT DISTINCT on an existing unique index triggers a DROP + CREATE cycle.
  • Test fixtures for the create_index/add_index case are updated across all four expected-output files (diff.sql, plan.json, plan.sql, plan.txt).

One style concern: the NULLS NOT DISTINCT emission in both generation functions is not guarded by index.Type == ir.IndexTypeUnique. While this is safe in the normal inspection → generate flow, adding the guard would prevent the generation of syntactically invalid DDL if an IR JSON file is manually crafted with "nulls_not_distinct": true on a non-unique index.

Confidence Score: 4/5

  • Safe to merge; the implementation is correct end-to-end with a minor defensive coding improvement suggested.
  • The feature is small and focused, the PostgreSQL DDL syntax is used correctly (NULLS NOT DISTINCT placed after columns and before WHERE), detection via pg_get_indexdef() is reliable, and structural equality is correctly updated. The one point deduction is for the missing index.Type == ir.IndexTypeUnique guard in the DDL generation functions, which could produce invalid SQL from a manually-edited IR JSON file.
  • internal/diff/index.go and internal/plan/rewrite.go — both lack the uniqueness type guard around NULLS NOT DISTINCT emission.

Important Files Changed

Filename Overview
internal/diff/index.go Adds NULLS NOT DISTINCT clause emission in generateIndexSQLWithName; placement (after column list, before WHERE) is correct per PostgreSQL syntax. Missing uniqueness guard — the clause is emitted for any index with NullsNotDistinct=true rather than only unique indexes.
internal/diff/table.go Adds NullsNotDistinct to the indexesStructurallyEqual comparison, ensuring a change to/from NULLS NOT DISTINCT correctly triggers index recreation. Change is correct and minimal.
internal/plan/rewrite.go Adds NULLS NOT DISTINCT emission to the plan rewrite path's generateIndexSQL. Placement is correct. Same missing uniqueness guard as in index.go — should check index.Type == ir.IndexTypeUnique alongside index.NullsNotDistinct.
ir/inspector.go Detects NULLS NOT DISTINCT from the pg_get_indexdef() output via a case-insensitive strings.Contains check. This is pragmatic and reliable since the clause only appears at a fixed position (after the column list) in the definition string returned by PostgreSQL.
ir/ir.go Adds NullsNotDistinct bool field to Index with correct omitempty JSON tag. Field alignment in the struct is now inconsistent with the preceding fields (Schema, Table, etc. are tab-aligned while the new block uses a different column width), but this is cosmetic only.
testdata/diff/create_index/add_index/new.sql Adds a CREATE UNIQUE INDEX ... NULLS NOT DISTINCT test fixture for the add_index scenario. Syntax is correct.
testdata/diff/create_index/add_index/plan.json Expected plan JSON updated with the new index entry; SQL, type, operation, and path are all correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User SQL file with\nCREATE UNIQUE INDEX ... NULLS NOT DISTINCT] --> B[Embedded PostgreSQL\napplies SQL]
    B --> C[Inspector: buildIndexes\npg_get_indexdef output]
    C --> D{strings.Contains\nNULLS NOT DISTINCT?}
    D -- Yes --> E[index.NullsNotDistinct = true]
    D -- No --> F[index.NullsNotDistinct = false]
    E --> G[IR: Index struct]
    F --> G

    G --> H{Diff path or\nPlan-rewrite path?}

    H -- diff path --> I[indexesStructurallyEqual\nchecks NullsNotDistinct]
    I -- changed --> J[DROP old index\nCREATE new index]
    I -- unchanged --> K[no-op]

    H -- generate DDL --> L[generateIndexSQLWithName\nor generateIndexSQL]
    L --> M["...columns...\nNULLS NOT DISTINCT\nWHERE ..."]
    M --> N[Final DDL output]

    style E fill:#d4edda
    style D fill:#fff3cd
    style M fill:#d4edda
Loading

Last reviewed commit: d2ef09c

Comment on lines +124 to +127
// NULLS NOT DISTINCT for unique indexes (PostgreSQL 15+)
if index.NullsNotDistinct {
builder.WriteString(" NULLS NOT DISTINCT")
}
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")
}

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 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds end-to-end support for PostgreSQL 15+ NULLS NOT DISTINCT on unique indexes so schema inspection, diffing, and plan/apply DDL generation preserve and recreate the clause when needed.

Changes:

  • Extend ir.Index with a NullsNotDistinct flag and populate it during inspection via pg_get_indexdef() output parsing.
  • Emit NULLS NOT DISTINCT in both diff SQL generation and plan rewrite index SQL generation; include it in structural equality comparisons.
  • Update create_index/add_index fixtures to cover the new clause and skip that test on PostgreSQL 14.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
testutil/skip_list.go Adds PG14-only skip list entry for the new PG15+ index syntax fixture.
testdata/diff/create_index/add_index/plan.txt Updates expected plan output to include the new unique index.
testdata/diff/create_index/add_index/plan.sql Updates expected plan SQL to include NULLS NOT DISTINCT.
testdata/diff/create_index/add_index/plan.json Updates expected JSON plan steps to include the new index DDL.
testdata/diff/create_index/add_index/new.sql Adds a NULLS NOT DISTINCT unique index to the input schema fixture.
testdata/diff/create_index/add_index/diff.sql Updates expected diff SQL to include NULLS NOT DISTINCT.
ir/ir.go Adds NullsNotDistinct to ir.Index for representing PG15+ behavior.
ir/inspector.go Detects NULLS NOT DISTINCT from pg_get_indexdef() and sets the IR flag.
internal/plan/rewrite.go Emits NULLS NOT DISTINCT when generating CREATE UNIQUE INDEX SQL in the rewrite path.
internal/diff/table.go Includes NullsNotDistinct in index structural equality checks.
internal/diff/index.go Emits NULLS NOT DISTINCT when generating CREATE UNIQUE INDEX SQL in the diff path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +64 to 76
// 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,
}
Comment on lines +760 to +762
// Check for NULLS NOT DISTINCT (PostgreSQL 15+)
if indexRow.Indexdef.Valid && strings.Contains(strings.ToUpper(indexRow.Indexdef.String), "NULLS NOT DISTINCT") {
index.NullsNotDistinct = true
- 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 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds end-to-end support for PostgreSQL 15+ NULLS NOT DISTINCT on unique indexes by tracking the property in IR, detecting it during inspection, and emitting it in both diff and plan-rewrite SQL generation so it’s not silently dropped.

Changes:

  • Extend ir.Index with a NullsNotDistinct flag and detect it from pg_get_indexdef() during inspection.
  • Emit NULLS NOT DISTINCT when generating CREATE UNIQUE INDEX SQL in both diff and plan rewrite paths; include it in structural equality to force index recreation on changes.
  • Update diff test fixtures to include a NULLS NOT DISTINCT unique index and skip the affected case on PG14.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ir/ir.go Adds NullsNotDistinct to the index IR model.
ir/inspector.go Sets NullsNotDistinct based on pg_get_indexdef() output.
internal/diff/index.go Renders NULLS NOT DISTINCT for unique index DDL in diff generation.
internal/diff/table.go Includes NullsNotDistinct in structural equality checks to trigger recreation.
internal/plan/rewrite.go Renders NULLS NOT DISTINCT for unique index DDL in rewrite generation.
testdata/diff/create_index/add_index/* Updates expected plans/diffs and adds a fixture index using NULLS NOT DISTINCT.
testutil/skip_list.go Adds a PG14-only skip list entry for the new fixture (feature unavailable on PG14).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

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 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds end-to-end support for PostgreSQL 15+ NULLS NOT DISTINCT on unique indexes by persisting the attribute in IR, detecting it during inspection, and emitting it consistently in diff/plan SQL output so schema changes recreate indexes when needed.

Changes:

  • Extend ir.Index with NullsNotDistinct and set it during inspection by scanning pg_get_indexdef() output.
  • Emit NULLS NOT DISTINCT in both diff DDL generation and plan rewrite index SQL generation paths.
  • Update index structural equality to include NullsNotDistinct, and update test fixtures + PG14 skipping for the new PG15-only feature.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
testutil/skip_list.go Skips the updated create_index/add_index fixture on PG14 since the fixture now contains a PG15+ index feature.
testdata/diff/create_index/add_index/plan.txt Fixture output updated to include the new unique index.
testdata/diff/create_index/add_index/plan.sql Fixture output updated to include NULLS NOT DISTINCT in generated SQL.
testdata/diff/create_index/add_index/plan.json Fixture output updated to include the new index SQL in JSON plan.
testdata/diff/create_index/add_index/new.sql Adds a NULLS NOT DISTINCT unique index to the test schema.
testdata/diff/create_index/add_index/diff.sql Fixture output updated to include NULLS NOT DISTINCT.
ir/ir.go Adds the NullsNotDistinct field to the Index IR model.
ir/inspector.go Detects NULLS NOT DISTINCT via pg_get_indexdef() output during index inspection.
internal/plan/rewrite.go Emits NULLS NOT DISTINCT when generating index SQL in the rewrite path.
internal/diff/table.go Includes NullsNotDistinct in structural index equality to force recreation on changes.
internal/diff/index.go Emits NULLS NOT DISTINCT in diff-generated CREATE UNIQUE INDEX statements.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +253 to +257
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"`
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds end-to-end support for PostgreSQL 15+ NULLS NOT DISTINCT on unique indexes so the inspector captures it, diffs treat it as a structural change, and both diff and plan DDL generators emit the clause.

Changes:

  • Extend ir.Index with NullsNotDistinct and populate it during inspection using pg_get_indexdef().
  • Emit NULLS NOT DISTINCT in both diff SQL generation and plan rewrite SQL generation (unique indexes only).
  • Update the create_index/add_index diff fixtures and skip that test on PG14 (feature not supported).

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.

Show a summary per file
File Description
ir/ir.go Adds NullsNotDistinct to the index IR model.
ir/inspector.go Detects NULLS NOT DISTINCT from pg_get_indexdef() output and stores it in IR.
internal/diff/table.go Includes NullsNotDistinct in structural equality so diffs trigger index recreation when it changes.
internal/diff/index.go Emits NULLS NOT DISTINCT for unique indexes in diff-generated CREATE UNIQUE INDEX.
internal/plan/rewrite.go Emits NULLS NOT DISTINCT for unique indexes in plan rewrite CREATE UNIQUE INDEX.
testdata/diff/create_index/add_index/* Updates golden fixtures to include the new unique index with NULLS NOT DISTINCT.
testutil/skip_list.go Skips create_index/add_index on PG14 to avoid running PG15+ syntax in PG14 test runs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

@tianzhou tianzhou merged commit 44bb45e into main Mar 13, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"nulls not distinct" being silently dropped when creating a unique index.

2 participants