Repository: ariga/atlas (go)

Issue:
Add support for NULLS NOT DISTINCT
This is a new Postgres 15 feature that's pretty important. It allows for unique indexes with nulls to treat each null as if it is not distinct. Otherwise, you can have a unique index on foo and bar and get loads of entries that look like they match as a human, but don't match as far as Postgres is concerned. Here's an example:

```SQL
CREATE TABLE lorem (
    foo text,
    bar text
)
CREATE UNIQUE INDEX lorem_foo_bar_idx ON lorem (foo, bar);

INSERT INTO lorem (foo, bar) VALUES
('hello', NULL),
('hello, NULL); -- this will succeed
```

NULLS NOT DISTINCT makes it so this isn't possible. Replace the above index with:

```SQL
CREATE UNIQUE INDEX lorem_foo_bar_idx ON lorem (foo, bar) NULLS NOT DISTINCT;
```

And voila,

```SQL
INSERT INTO lorem (foo, bar) VALUES
('hello', NULL),
('hello, NULL); -- this will now fail since each null is the same value and will match with each other!
```

It appears that this is part of the SQL standard, https://modern-sql.com/caniuse/unique-nulls-not-distinct

So, I'd propose that a new config option be added to an index that is creatively named `nullsNotDistinct`. When set to true, it adds the `NULLS NOT DISTINCT` identifier.

Pull request:
sql/postgres: support the NULLS [NOT] DISTINCT index attribute
Closes #1939

Patch:
diff --git a/sql/postgres/diff.go b/sql/postgres/diff.go
index 285b2a31..bcc1220a 100644
--- a/sql/postgres/diff.go
+++ b/sql/postgres/diff.go
@@ -224,6 +224,9 @@ func (*diff) IndexAttrChanged(from, to []schema.Attr) bool {
 	if t1.T != t2.T {
 		return true
 	}
+	if indexNullsDistinct(to) != indexNullsDistinct(from) {
+		return true
+	}
 	var p1, p2 IndexPredicate
 	if sqlx.Has(from, &p1) != sqlx.Has(to, &p2) || (p1.P != p2.P && p1.P != sqlx.MayWrap(p2.P)) {
 		return true
@@ -510,6 +513,16 @@ func indexIncludeChanged(from, to []schema.Attr) bool {
 	return false
 }
 
+// indexNullsDistinct returns the NULLS [NOT] DISTINCT value from the index attributes.
+func indexNullsDistinct(attrs []schema.Attr) bool {
+	if i := (IndexNullsDistinct{}); sqlx.Has(attrs, &i) {
+		return i.V
+	}
+	// The default PostgreSQL behavior. The inverse of
+	// "indnullsnotdistinct" in pg_index which is false.
+	return true
+}
+
 func trimCast(s string) string {
 	i := strings.LastIndex(s, "::")
 	if i == -1 {
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index 2b2886b1..f19e5bbb 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -317,6 +317,11 @@ func (c *conn) supportsIndexInclude() bool {
 	return c.version >= 11_00_00
 }
 
+// supportsIndexNullsDistinct reports if the server supports the NULLS [NOT] DISTINCT clause.
+func (c *conn) supportsIndexNullsDistinct() bool {
+	return c.version >= 15_00_00
+}
+
 type parser struct{}
 
 // ParseURL implements the sqlclient.URLParser interface.
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index ee7d9022..4f2ccbd8 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -336,12 +336,16 @@ func (i *inspect) inspectEnums(ctx context.Context, r *schema.Realm) error {
 
 // indexes queries and appends the indexes of the given table.
 func (i *inspect) indexes(ctx context.Context, s *schema.Schema) error {
-	query := indexesQuery
+	var query string
 	switch {
 	case i.conn.crdb:
 		return i.crdbIndexes(ctx, s)
-	case !i.conn.supportsIndexInclude():
-		query = indexesQueryNoInclude
+	case i.supportsIndexNullsDistinct():
+		query = indexesAbove15
+	case i.supportsIndexInclude():
+		query = indexesAbove11
+	default:
+		query = indexesBelow11
 	}
 	rows, err := i.querySchema(ctx, query, s)
 	if err != nil {
@@ -359,14 +363,14 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 	names := make(map[string]*schema.Index)
 	for rows.Next() {
 		var (
-			uniq, primary, included                                               bool
 			table, name, typ                                                      string
+			uniq, primary, included, nullsnotdistinct                             bool
 			desc, nullsfirst, nullslast, opcdefault                               sql.NullBool
 			column, constraints, pred, expr, comment, options, opcname, opcparams sql.NullString
 		)
 		if err := rows.Scan(
-			&table, &name, &typ, &column, &included, &primary, &uniq, &constraints, &pred, &expr,
-			&desc, &nullsfirst, &nullslast, &comment, &options, &opcname, &opcdefault, &opcparams,
+			&table, &name, &typ, &column, &included, &primary, &uniq, &constraints, &pred, &expr, &desc,
+			&nullsfirst, &nullslast, &comment, &options, &opcname, &opcdefault, &opcparams, &nullsnotdistinct,
 		); err != nil {
 			return fmt.Errorf("postgres: scanning indexes for schema %q: %w", s.Name, err)
 		}
@@ -393,18 +397,21 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 					return fmt.Errorf("postgres: unmarshaling index constraints: %w", err)
 				}
 				for n, t := range m {
-					idx.Attrs = append(idx.Attrs, &Constraint{N: n, T: t})
+					idx.AddAttrs(&Constraint{N: n, T: t})
 				}
 			}
 			if sqlx.ValidString(pred) {
-				idx.Attrs = append(idx.Attrs, &IndexPredicate{P: pred.String})
+				idx.AddAttrs(&IndexPredicate{P: pred.String})
 			}
 			if sqlx.ValidString(options) {
 				p, err := newIndexStorage(options.String)
 				if err != nil {
 					return err
 				}
-				idx.Attrs = append(idx.Attrs, p)
+				idx.AddAttrs(p)
+			}
+			if nullsnotdistinct {
+				idx.AddAttrs(&IndexNullsDistinct{V: false})
 			}
 			names[name] = idx
 			if primary {
@@ -873,6 +880,12 @@ type (
 		Params  []struct{ N, V string } // Optional parameters.
 	}
 
+	// IndexNullsDistinct describes the NULLS [NOT] DISTINCT clause.
+	IndexNullsDistinct struct {
+		schema.Attr
+		V bool // NULLS [NOT] DISTINCT. Defaults to true.
+	}
+
 	// Concurrently describes the CONCURRENTLY clause to instruct Postgres to
 	// build or drop the index concurrently without blocking the current table.
 	// https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY
@@ -1313,9 +1326,10 @@ ORDER BY
 )
 
 var (
-	indexesQuery          = fmt.Sprintf(indexesQueryTmpl, "(a.attname <> '' AND idx.indnatts > idx.indnkeyatts AND idx.ord > idx.indnkeyatts)", "%s")
-	indexesQueryNoInclude = fmt.Sprintf(indexesQueryTmpl, "false", "%s")
-	indexesQueryTmpl      = `
+	indexesBelow11   = fmt.Sprintf(indexesQueryTmpl, "false", "false", "%s")
+	indexesAbove11   = fmt.Sprintf(indexesQueryTmpl, "(a.attname <> '' AND idx.indnatts > idx.indnkeyatts AND idx.ord > idx.indnkeyatts)", "false", "%s")
+	indexesAbove15   = fmt.Sprintf(indexesQueryTmpl, "(a.attname <> '' AND idx.indnatts > idx.indnkeyatts AND idx.ord > idx.indnkeyatts)", "idx.indnullsnotdistinct", "%s")
+	indexesQueryTmpl = `
 SELECT
 	t.relname AS table_name,
 	i.relname AS index_name,
@@ -1334,7 +1348,8 @@ SELECT
 	i.reloptions AS options,
 	op.opcname AS opclass_name,
 	op.opcdefault AS opclass_default,
-	a2.attoptions AS opclass_params
+	a2.attoptions AS opclass_params,
+    %s AS indnullsnotdistinct
 FROM
 	(
 		select
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index 29b16f64..7bad687f 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -1009,6 +1009,10 @@ func (s *state) index(b *sqlx.Builder, idx *schema.Index) error {
 			})
 		})
 	}
+	// Avoid appending the default behavior, which NULL values are distinct.
+	if n := (IndexNullsDistinct{}); sqlx.Has(idx.Attrs, &n) && !n.V {
+		b.P("NULLS NOT DISTINCT")
+	}
 	if p, ok := indexStorageParams(idx.Attrs); ok {
 		b.P("WITH")
 		b.Wrap(func(b *sqlx.Builder) {
@@ -1027,7 +1031,7 @@ func (s *state) index(b *sqlx.Builder, idx *schema.Index) error {
 	}
 	for _, attr := range idx.Attrs {
 		switch attr.(type) {
-		case *schema.Comment, *IndexType, *IndexInclude, *Constraint, *IndexPredicate, *IndexStorageParams:
+		case *schema.Comment, *IndexType, *IndexInclude, *Constraint, *IndexPredicate, *IndexStorageParams, *IndexNullsDistinct:
 		default:
 			return fmt.Errorf("postgres: unexpected index attribute: %T", attr)
 		}
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index d6e4b0a3..938362ab 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -363,6 +363,13 @@ func convertIndex(spec *sqlspec.Index, t *schema.Table) (*schema.Index, error) {
 		}
 		idx.Attrs = append(idx.Attrs, &IndexPredicate{P: p})
 	}
+	if attr, ok := spec.Attr("nulls_distinct"); ok {
+		v, err := attr.Bool()
+		if err != nil {
+			return nil, err
+		}
+		idx.Attrs = append(idx.Attrs, &IndexNullsDistinct{V: v})
+	}
 	if err := convertIndexPK(spec, t, idx); err != nil {
 		return nil, err
 	}
@@ -605,6 +612,9 @@ func indexSpec(idx *schema.Index) (*sqlspec.Index, error) {
 	if i := (IndexPredicate{}); sqlx.Has(idx.Attrs, &i) && i.P != "" {
 		spec.Extra.Attrs = append(spec.Extra.Attrs, specutil.VarAttr("where", strconv.Quote(i.P)))
 	}
+	if i := (IndexNullsDistinct{}); sqlx.Has(idx.Attrs, &i) && !i.V {
+		spec.Extra.Attrs = append(spec.Extra.Attrs, schemahcl.BoolAttr("nulls_distinct", i.V))
+	}
 	spec.Extra.Attrs = indexPKSpec(idx, spec.Extra.Attrs)
 	return spec, nil
 }


Test patch:
diff --git a/internal/integration/testdata/postgres/index-nulls-distinct.txt b/internal/integration/testdata/postgres/index-nulls-distinct.txt
new file mode 100644
index 00000000..cc50801b
--- /dev/null
+++ b/internal/integration/testdata/postgres/index-nulls-distinct.txt
@@ -0,0 +1,85 @@
+only postgres15
+
+apply 1.hcl
+cmphcl 1.inspect.hcl
+cmpshow users 1.sql
+
+# Change the nulls_distinct property of the index.
+apply 2.hcl
+cmphcl 2.inspect.hcl
+cmpshow users 2.sql
+
+-- 1.hcl --
+schema "script_index_nulls_distinct" {}
+
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    type = int
+  }
+  index "nulls_not_distinct" {
+    unique         = true
+    columns        = [column.c]
+    nulls_distinct = false
+  }
+}
+
+-- 1.inspect.hcl --
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    null = false
+    type = integer
+  }
+  index "nulls_not_distinct" {
+    unique         = true
+    columns        = [column.c]
+    nulls_distinct = false
+  }
+}
+schema "script_index_nulls_distinct" {
+}
+-- 1.sql --
+     Table "script_index_nulls_distinct.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "nulls_not_distinct" UNIQUE, btree (c) NULLS NOT DISTINCT
+
+-- 2.hcl --
+schema "script_index_nulls_distinct" {}
+
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    type = int
+  }
+  index "nulls_not_distinct" {
+    unique  = true
+    columns = [column.c]
+  }
+}
+
+-- 2.inspect.hcl --
+table "users" {
+  schema = schema.script_index_nulls_distinct
+  column "c" {
+    null = false
+    type = integer
+  }
+  index "nulls_not_distinct" {
+    unique  = true
+    columns = [column.c]
+  }
+}
+schema "script_index_nulls_distinct" {
+}
+-- 2.sql --
+     Table "script_index_nulls_distinct.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "nulls_not_distinct" UNIQUE, btree (c)
+
diff --git a/sql/postgres/diff_test.go b/sql/postgres/diff_test.go
index cceda18f..02515d6b 100644
--- a/sql/postgres/diff_test.go
+++ b/sql/postgres/diff_test.go
@@ -328,6 +328,7 @@ func TestDiff_TableDiff(t *testing.T) {
 				{Name: "c5_include_no_change", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
 				{Name: "c5_include_added", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
 				{Name: "c5_include_dropped", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
+				{Name: "c6_nulls_not_distinct", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexNullsDistinct{V: true}}},
 			}
 			to.Indexes = []*schema.Index{
 				{Name: "c1_index", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[0]}}},
@@ -338,6 +339,7 @@ func TestDiff_TableDiff(t *testing.T) {
 				{Name: "c5_include_no_change", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
 				{Name: "c5_include_added", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexInclude{Columns: from.Columns[:1]}}},
 				{Name: "c5_include_dropped", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
+				{Name: "c6_nulls_not_distinct", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexNullsDistinct{V: false}}},
 			}
 			return testcase{
 				name: "indexes",
@@ -350,6 +352,7 @@ func TestDiff_TableDiff(t *testing.T) {
 					&schema.ModifyIndex{From: from.Indexes[4], To: to.Indexes[4], Change: schema.ChangeAttr},
 					&schema.ModifyIndex{From: from.Indexes[6], To: to.Indexes[6], Change: schema.ChangeAttr},
 					&schema.ModifyIndex{From: from.Indexes[7], To: to.Indexes[7], Change: schema.ChangeAttr},
+					&schema.ModifyIndex{From: from.Indexes[8], To: to.Indexes[8], Change: schema.ChangeAttr},
 					&schema.AddIndex{I: to.Indexes[1]},
 				},
 			}
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index 2ce1d7b9..2631a186 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -26,7 +26,7 @@ var (
 	queryChecks      = sqltest.Escape(fmt.Sprintf(checksQuery, "$2"))
 	queryColumns     = sqltest.Escape(fmt.Sprintf(columnsQuery, "$2"))
 	queryCRDBColumns = sqltest.Escape(fmt.Sprintf(crdbColumnsQuery, "$2"))
-	queryIndexes     = sqltest.Escape(fmt.Sprintf(indexesQuery, "$2"))
+	queryIndexes     = sqltest.Escape(fmt.Sprintf(indexesAbove15, "$2"))
 	queryCRDBIndexes = sqltest.Escape(fmt.Sprintf(crdbIndexesQuery, "$2"))
 )
 
@@ -174,23 +174,23 @@ users      | ts          | tsvector            | tsvector  |  NO          |
 				m.ExpectQuery(queryIndexes).
 					WithArgs("public", "users").
 					WillReturnRows(sqltest.Rows(`
-   table_name   |    index_name   | index_type  | column_name | included | primary | unique |   constraints   | predicate             |   expression              | desc | nulls_first | nulls_last | comment   |                 options               |   opclass_name    | opclass_default | opclass_params 
-----------------+-----------------+-------------+-------------+----------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+---------------------------------------+-------------------+-----------------+----------------
-users           | idx             | hash        |             | f        | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |                                       |     int4_ops      |        t        |
-users           | idx1            | btree       |             | f        | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |                                       |     int4_ops      |        t        |
-users           | t1_c1_key       | btree       | c1          | f        | f       | t      | {"name": "u"}   |                       | c1                        | t    | t           | f          |           |                                       |     int4_ops      |        t        |
-users           | t1_pkey         | btree       | id          | f        | t       | t      | {"t_pkey": "p"} |                       | id                        | t    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx4            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx4            | btree       | id          | f        | f       | t      |                 |                       | id                        | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx5            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx5            | btree       |             | f        | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |                                       |     int4_ops      |        t        |
-users           | idx6            | brin        | c1          | f        | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}|     int4_ops      |        t        |
-users           | idx2            | btree       |             | f        | f       | f      |                 |                       | ((c * 2))                 | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | c1          | f        | f       | f      |                 |                       | c                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | id          | f        | f       | f      |                 |                       | d                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | c1          | t        | f       | f      |                 |                       | c                         |      |             |            |           |                                       |     int4_ops      |        t        |
-users           | idx2            | btree       | parent_id   | t        | f       | f      |                 |                       | d                         |      |             |            |           |                                       |     int4_ops      |        t        |
-users           | tsx             | gist        | ts          | f        | f       | f      |                 |                       | ts                        |      |             |            |           |                                       |     tsvector_ops  |        f        | {siglen=1}
+   table_name   |    index_name   | index_type  | column_name | included | primary | unique |   constraints   | predicate             |   expression              | desc | nulls_first | nulls_last | comment   |                 options               |   opclass_name    | opclass_default | opclass_params | indnullsnotdistinct 
+----------------+-----------------+-------------+-------------+----------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+---------------------------------------+-------------------+-----------------+----------------+---------------------
+users           | idx             | hash        |             | f        | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |                                       |     int4_ops      |        t        |                | f
+users           | idx1            | btree       |             | f        | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | t1_c1_key       | btree       | c1          | f        | f       | t      | {"name": "u"}   |                       | c1                        | t    | t           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | t1_pkey         | btree       | id          | f        | t       | t      | {"t_pkey": "p"} |                       | id                        | t    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx4            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx4            | btree       | id          | f        | f       | t      |                 |                       | id                        | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx5            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx5            | btree       |             | f        | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx6            | brin        | c1          | f        | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}|     int4_ops      |        t        |                | f
+users           | idx2            | btree       |             | f        | f       | f      |                 |                       | ((c * 2))                 | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | c1          | f        | f       | f      |                 |                       | c                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | id          | f        | f       | f      |                 |                       | d                         | f    | f           | t          |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | c1          | t        | f       | f      |                 |                       | c                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
+users           | idx2            | btree       | parent_id   | t        | f       | f      |                 |                       | d                         |      |             |            |           |                                       |     int4_ops      |        t        |                | f
+users           | tsx             | gist        | ts          | f        | f       | f      |                 |                       | ts                        |      |             |            |           |                                       |     tsvector_ops  |        f        | {siglen=1}     | f
 `))
 				m.noFKs()
 				m.noChecks()
@@ -333,7 +333,7 @@ users        | users_check1       | (((c2 + c1) + c3) > 10) | c3          | {2,1
 			db, m, err := sqlmock.New()
 			require.NoError(t, err)
 			mk := mock{m}
-			mk.version("130000")
+			mk.version("150000")
 			var drv migrate.Driver
 			drv, err = Open(db)
 			require.NoError(t, err)
@@ -358,7 +358,7 @@ func TestDriver_InspectPartitionedTable(t *testing.T) {
 	db, m, err := sqlmock.New()
 	require.NoError(t, err)
 	mk := mock{m}
-	mk.version("130000")
+	mk.version("150000")
 	drv, err := Open(db)
 	require.NoError(t, err)
 	mk.ExpectQuery(sqltest.Escape(fmt.Sprintf(schemasQueryArgs, "= CURRENT_SCHEMA()"))).
@@ -388,8 +388,8 @@ logs2      | c3         | integer   | integer   | NO          |                |
 logs3      | c4         | integer   | integer   | NO          |                |                          |                32 |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |  23
 logs3      | c5         | integer   | integer   | NO          |                |                          |                32 |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |  23
 `))
-	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(indexesQuery, "$2, $3, $4"))).
-		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression"}))
+	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(indexesAbove15, "$2, $3, $4"))).
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options", "indnullsnotdistinct"}))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(fksQuery, "$2, $3, $4"))).
 		WillReturnRows(sqlmock.NewRows([]string{"constraint_name", "table_name", "column_name", "referenced_table_name", "referenced_column_name", "referenced_table_schema", "update_rule", "delete_rule"}))
 	m.ExpectQuery(sqltest.Escape(fmt.Sprintf(checksQuery, "$2, $3, $4"))).
@@ -754,7 +754,7 @@ func (m mock) tableExists(schema, table string, exists bool) {
 
 func (m mock) noIndexes() {
 	m.ExpectQuery(queryIndexes).
-		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options", "indnullsnotdistinct"}))
 }
 
 func (m mock) noFKs() {
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index 77f2fa92..eff59c10 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -617,6 +617,30 @@ func TestPlanChanges(t *testing.T) {
 									},
 								},
 							},
+							&schema.AddIndex{
+								I: &schema.Index{
+									Unique: true,
+									Name:   "nulls_distinct",
+									Parts: []*schema.IndexPart{
+										{C: users.Columns[0]},
+									},
+									Attrs: []schema.Attr{
+										&IndexNullsDistinct{V: true},
+									},
+								},
+							},
+							&schema.AddIndex{
+								I: &schema.Index{
+									Unique: true,
+									Name:   "nulls_not_distinct",
+									Parts: []*schema.IndexPart{
+										{C: users.Columns[0]},
+									},
+									Attrs: []schema.Attr{
+										&IndexNullsDistinct{V: false},
+									},
+								},
+							},
 						},
 					}
 				}(),
@@ -653,6 +677,14 @@ func TestPlanChanges(t *testing.T) {
 						Cmd:     `CREATE INDEX "operator_class" ON "users" USING BRIN ("id" int8_bloom_ops, "id", "id" int8_minmax_multi_ops(values_per_range=8))`,
 						Reverse: `DROP INDEX "operator_class"`,
 					},
+					{
+						Cmd:     `CREATE UNIQUE INDEX "nulls_distinct" ON "users" ("id")`,
+						Reverse: `DROP INDEX "nulls_distinct"`,
+					},
+					{
+						Cmd:     `CREATE UNIQUE INDEX "nulls_not_distinct" ON "users" ("id") NULLS NOT DISTINCT`,
+						Reverse: `DROP INDEX "nulls_not_distinct"`,
+					},
 					{
 						Cmd:     `COMMENT ON COLUMN "users" ."name" IS 'foo'`,
 						Reverse: `COMMENT ON COLUMN "users" ."name" IS ''`,
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index 56c115b8..56cb2408 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -827,6 +827,54 @@ schema "test" {
 	require.EqualValues(t, expected, string(buf))
 }
 
+func TestMarshalSpec_IndexNullsDistinct(t *testing.T) {
+	s := schema.New("public").
+		AddTables(
+			schema.NewTable("users").
+				AddColumns(
+					schema.NewIntColumn("c", "int"),
+				).
+				AddIndexes(
+					// Default behavior.
+					schema.NewUniqueIndex("without_attribute").
+						AddColumns(schema.NewColumn("c")),
+					schema.NewUniqueIndex("with_nulls_distinct").
+						AddColumns(schema.NewColumn("c")).
+						AddAttrs(&IndexNullsDistinct{V: true}),
+					// Explicitly disable (NULLS NOT DISTINCT).
+					schema.NewUniqueIndex("with_nulls_not_distinct").
+						AddColumns(schema.NewColumn("c")).
+						AddAttrs(&IndexNullsDistinct{V: false}),
+				),
+		)
+	buf, err := MarshalSpec(s, hclState)
+	require.NoError(t, err)
+	const expected = `table "users" {
+  schema = schema.public
+  column "c" {
+    null = false
+    type = int
+  }
+  index "without_attribute" {
+    unique  = true
+    columns = [column.c]
+  }
+  index "with_nulls_distinct" {
+    unique  = true
+    columns = [column.c]
+  }
+  index "with_nulls_not_distinct" {
+    unique         = true
+    columns        = [column.c]
+    nulls_distinct = false
+  }
+}
+schema "public" {
+}
+`
+	require.EqualValues(t, expected, string(buf))
+}
+
 func TestMarshalSpec_BRINIndex(t *testing.T) {
 	s := &schema.Schema{
 		Name: "test",