Repository: ariga/atlas
Pull request #788 (2022-05-11 17:11:13)

sql/postgres: add support for block range indexes


Patch:
diff --git a/doc/md/ddl/sql.mdx b/doc/md/ddl/sql.mdx
index 91eb387e..fbd9dcd7 100644
--- a/doc/md/ddl/sql.mdx
+++ b/doc/md/ddl/sql.mdx
@@ -400,6 +400,12 @@ index "idx_name" {
   type = GIN
   columns = [column.data]
 }
+
+index "idx_range" {
+  type = BRIN
+  columns = [column.range]
+  page_per_range = 128
+}
 ```
 
 #### Properties
@@ -410,6 +416,7 @@ index "idx_name" {
 | type      | attribute | IndexType (enum)        | Defines the index type. e.g. `HASH`, `GIN`, `FULLTEXT`.        |
 | columns   | attribute | reference (list)        | The columns that comprise the index.                           |
 | on        | resource  | schema.IndexPart (list) | The index parts that comprise the index.                       |
+| options   | attribute | schema.Attr             | Additional driver specific attributes. e.g. `page_per_range`   |
 
 ### Index Expressions
 
diff --git a/sql/postgres/diff.go b/sql/postgres/diff.go
index 382e6aa9..d61c0a59 100644
--- a/sql/postgres/diff.go
+++ b/sql/postgres/diff.go
@@ -161,7 +161,12 @@ func (*diff) IndexAttrChanged(from, to []schema.Attr) bool {
 		return true
 	}
 	var p1, p2 IndexPredicate
-	return sqlx.Has(from, &p1) != sqlx.Has(to, &p2) || (p1.P != p2.P && p1.P != sqlx.MayWrap(p2.P))
+	if sqlx.Has(from, &p1) != sqlx.Has(to, &p2) || (p1.P != p2.P && p1.P != sqlx.MayWrap(p2.P)) {
+		return true
+	}
+	s1, ok1 := indexStorageParams(from)
+	s2, ok2 := indexStorageParams(to)
+	return ok1 != ok2 || ok1 && *s1 != *s2
 }
 
 // IndexPartAttrChanged reports if the index-part attributes were changed.
@@ -407,6 +412,19 @@ func formatPartition(p Partition) (string, error) {
 	return b.String(), nil
 }
 
+// indexStorageParams returns the index storage parameters from the attributes
+// in case it is there, and it is not the default.
+func indexStorageParams(attrs []schema.Attr) (*IndexStorageParams, bool) {
+	s := &IndexStorageParams{}
+	if !sqlx.Has(attrs, s) {
+		return nil, false
+	}
+	if !s.AutoSummarize && (s.PagesPerRange == 0 || s.PagesPerRange == defaultPagePerRange) {
+		return nil, false
+	}
+	return s, 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 be523d5b..06b21979 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -231,10 +231,12 @@ const (
 
 // List of supported index types.
 const (
-	IndexTypeBTree = "BTREE"
-	IndexTypeHash  = "HASH"
-	IndexTypeGIN   = "GIN"
-	IndexTypeGiST  = "GIST"
+	IndexTypeBTree      = "BTREE"
+	IndexTypeHash       = "HASH"
+	IndexTypeGIN        = "GIN"
+	IndexTypeGiST       = "GIST"
+	IndexTypeBRIN       = "BRIN"
+	defaultPagePerRange = 128
 )
 
 // List of "GENERATED" types.
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index 912190f8..e88eccc5 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -349,12 +349,12 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 	names := make(map[string]*schema.Index)
 	for rows.Next() {
 		var (
-			uniq, primary                        bool
-			table, name, typ                     string
-			desc, nullsfirst, nullslast          sql.NullBool
-			column, contype, pred, expr, comment sql.NullString
+			uniq, primary                                 bool
+			table, name, typ                              string
+			desc, nullsfirst, nullslast                   sql.NullBool
+			column, contype, pred, expr, comment, options sql.NullString
 		)
-		if err := rows.Scan(&table, &name, &typ, &column, &primary, &uniq, &contype, &pred, &expr, &desc, &nullsfirst, &nullslast, &comment); err != nil {
+		if err := rows.Scan(&table, &name, &typ, &column, &primary, &uniq, &contype, &pred, &expr, &desc, &nullsfirst, &nullslast, &comment, &options); err != nil {
 			return fmt.Errorf("postgres: scanning indexes for schema %q: %w", s.Name, err)
 		}
 		t, ok := s.Table(table)
@@ -380,6 +380,13 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 			if sqlx.ValidString(pred) {
 				idx.Attrs = append(idx.Attrs, &IndexPredicate{P: pred.String})
 			}
+			if sqlx.ValidString(options) {
+				p, err := newIndexStorage(options.String)
+				if err != nil {
+					return err
+				}
+				idx.Attrs = append(idx.Attrs, p)
+			}
 			names[name] = idx
 			if primary {
 				t.PrimaryKey = idx
@@ -736,6 +743,17 @@ type (
 		NullsLast bool
 	}
 
+	// IndexStorageParams describes index storage parameters add with the WITH clause.
+	// https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-STORAGE-PARAMETERS
+	IndexStorageParams struct {
+		schema.Attr
+		// AutoSummarize defines the authsummarize storage parameter.
+		AutoSummarize bool
+		// PagesPerRange defines pages_per_range storage
+		// parameter for BRIN indexes. Defaults to 128.
+		PagesPerRange int64
+	}
+
 	// NoInherit attribute defines the NO INHERIT flag for CHECK constraint.
 	// https://www.postgresql.org/docs/current/catalog-pg-constraint.html
 	NoInherit struct {
@@ -773,6 +791,32 @@ type (
 	}
 )
 
+// newIndexStorage parses and returns the index storage parameters.
+func newIndexStorage(opts string) (*IndexStorageParams, error) {
+	params := &IndexStorageParams{}
+	for _, p := range strings.Split(strings.Trim(opts, "{}"), ",") {
+		kv := strings.Split(p, "=")
+		if len(kv) != 2 {
+			return nil, fmt.Errorf("invalid index storage parameter: %s", p)
+		}
+		switch kv[0] {
+		case "autosummarize":
+			b, err := strconv.ParseBool(kv[1])
+			if err != nil {
+				return nil, fmt.Errorf("failed parsing autosummarize %q: %w", kv[1], err)
+			}
+			params.AutoSummarize = b
+		case "pages_per_range":
+			i, err := strconv.ParseInt(kv[1], 10, 64)
+			if err != nil {
+				return nil, fmt.Errorf("failed parsing pages_per_range %q: %w", kv[1], err)
+			}
+			params.PagesPerRange = i
+		}
+	}
+	return params, nil
+}
+
 const (
 	// Query to list runtime parameters.
 	paramsQuery = `SELECT setting FROM pg_settings WHERE name IN ('lc_collate', 'lc_ctype', 'server_version_num') ORDER BY name`
@@ -869,7 +913,8 @@ SELECT
 	pg_index_column_has_property(idx.indexrelid, a.attnum, 'desc') AS desc,
 	pg_index_column_has_property(idx.indexrelid, a.attnum, 'nulls_first') AS nulls_first,
 	pg_index_column_has_property(idx.indexrelid, a.attnum, 'nulls_last') AS nulls_last,
-	obj_description(to_regclass($1 || i.relname)::oid) AS comment
+	obj_description(to_regclass($1 || i.relname)::oid) AS comment,
+	i.reloptions AS options
 FROM
 	(
 		select
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index f419b982..58110911 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -729,9 +729,22 @@ func (s *state) index(b *sqlx.Builder, idx *schema.Index) {
 	if p := (IndexPredicate{}); sqlx.Has(idx.Attrs, &p) {
 		b.P("WHERE").P(p.P)
 	}
+	if p, ok := indexStorageParams(idx.Attrs); ok {
+		b.P("WITH")
+		b.Wrap(func(b *sqlx.Builder) {
+			var parts []string
+			if p.AutoSummarize {
+				parts = append(parts, "autosummarize = true")
+			}
+			if p.PagesPerRange != 0 && p.PagesPerRange != defaultPagePerRange {
+				parts = append(parts, fmt.Sprintf("pages_per_range = %d", p.PagesPerRange))
+			}
+			b.WriteString(strings.Join(parts, ", "))
+		})
+	}
 	for _, attr := range idx.Attrs {
 		switch attr.(type) {
-		case *schema.Comment, *ConType, *IndexType, *IndexPredicate:
+		case *schema.Comment, *ConType, *IndexType, *IndexPredicate, *IndexStorageParams:
 		default:
 			panic(fmt.Sprintf("unexpected index attribute: %T", attr))
 		}
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index e7e574b6..0d75e3d5 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -105,7 +105,7 @@ func MarshalSpec(v interface{}, marshaler schemaspec.Marshaler) ([]byte, error)
 var (
 	hclState = schemahcl.New(
 		schemahcl.WithTypes(TypeRegistry.Specs()),
-		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeHash, IndexTypeGIN, IndexTypeGiST),
+		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeHash, IndexTypeGIN, IndexTypeGiST, IndexTypeBRIN),
 		schemahcl.WithScopedEnums("table.partition.type", PartitionTypeRange, PartitionTypeList, PartitionTypeHash),
 		schemahcl.WithScopedEnums("table.column.identity.generated", GeneratedTypeAlways, GeneratedTypeByDefault),
 		schemahcl.WithScopedEnums("table.column.as.type", "STORED"),
@@ -307,6 +307,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("page_per_range"); ok {
+		p, err := attr.Int64()
+		if err != nil {
+			return nil, err
+		}
+		idx.Attrs = append(idx.Attrs, &IndexStorageParams{PagesPerRange: p})
+	}
 	return idx, nil
 }
 
@@ -445,6 +452,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 p, ok := indexStorageParams(idx.Attrs); ok {
+		spec.Extra.Attrs = append(spec.Extra.Attrs, specutil.Int64Attr("page_per_range", p.PagesPerRange))
+	}
 	return spec, nil
 }
 


Test patch:
diff --git a/internal/integration/testdata/postgres/index-type-brin.txt b/internal/integration/testdata/postgres/index-type-brin.txt
new file mode 100644
index 00000000..7f972bfd
--- /dev/null
+++ b/internal/integration/testdata/postgres/index-type-brin.txt
@@ -0,0 +1,82 @@
+apply 1.hcl
+cmpshow users 1.sql
+
+# Add the "page_per_range" storage parameter.
+apply 2.hcl
+cmpshow users 2.sql
+
+# Change the "page_per_range" storage parameter.
+apply 3.hcl
+cmpshow users 3.sql
+
+-- 1.hcl --
+schema "$db" {}
+
+table "users" {
+  schema = schema.$db
+  column "c" {
+    null = false
+    type = int
+  }
+  index "users_c" {
+    type = BRIN
+    columns = [column.c]
+  }
+}
+
+-- 1.sql --
+       Table "script_index_type_brin.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "users_c" brin (c)
+
+-- 2.hcl --
+schema "$db" {}
+
+table "users" {
+  schema = schema.$db
+  column "c" {
+    null = false
+    type = int
+  }
+  index "users_c" {
+    type = BRIN
+    columns = [column.c]
+    page_per_range = 2
+  }
+}
+
+-- 2.sql --
+       Table "script_index_type_brin.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "users_c" brin (c) WITH (pages_per_range='2')
+
+
+-- 3.hcl --
+schema "$db" {}
+
+table "users" {
+  schema = schema.$db
+  column "c" {
+    null = false
+    type = int
+  }
+  index "users_c" {
+    type = BRIN
+    columns = [column.c]
+    page_per_range = 3
+  }
+}
+
+-- 3.sql --
+       Table "script_index_type_brin.users"
+ Column |  Type   | Collation | Nullable | Default
+--------+---------+-----------+----------+---------
+ c      | integer |           | not null |
+Indexes:
+    "users_c" brin (c) WITH (pages_per_range='3')
\ No newline at end of file
diff --git a/sql/postgres/diff_test.go b/sql/postgres/diff_test.go
index 17a27e79..530e2ff7 100644
--- a/sql/postgres/diff_test.go
+++ b/sql/postgres/diff_test.go
@@ -279,12 +279,14 @@ func TestDiff_TableDiff(t *testing.T) {
 				{Name: "c2_unique", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
 				{Name: "c3_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}},
 				{Name: "c4_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexPredicate{P: "(c4 <> NULL)"}}},
+				{Name: "c4_storage_params", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexStorageParams{PagesPerRange: 4}}},
 			}
 			to.Indexes = []*schema.Index{
 				{Name: "c1_index", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[0]}}},
 				{Name: "c3_unique", Unique: true, Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: to.Columns[1]}}},
 				{Name: "c3_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexPredicate{P: "c3 <> NULL"}}},
 				{Name: "c4_predicate", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexPredicate{P: "c4 <> NULL"}}},
+				{Name: "c4_storage_params", Table: from, Parts: []*schema.IndexPart{{SeqNo: 1, C: from.Columns[1]}}, Attrs: []schema.Attr{&IndexStorageParams{PagesPerRange: 2}}},
 			}
 			return testcase{
 				name: "indexes",
@@ -294,6 +296,7 @@ func TestDiff_TableDiff(t *testing.T) {
 					&schema.ModifyIndex{From: from.Indexes[0], To: to.Indexes[0], Change: schema.ChangeUnique},
 					&schema.DropIndex{I: from.Indexes[1]},
 					&schema.ModifyIndex{From: from.Indexes[2], To: to.Indexes[2], Change: schema.ChangeAttr},
+					&schema.ModifyIndex{From: from.Indexes[4], To: to.Indexes[4], Change: schema.ChangeAttr},
 					&schema.AddIndex{I: to.Indexes[1]},
 				},
 			}
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index 371b106b..0b04e68a 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -137,16 +137,17 @@ users      | parent_id   | bigint              | YES         |
 				m.ExpectQuery(queryIndexes).
 					WithArgs("public", "users").
 					WillReturnRows(sqltest.Rows(`
-   table_name   |    index_name   | index_type  | column_name | primary | unique | constraint_type | predicate             |   expression              | desc | nulls_first | nulls_last | comment
-----------------+-----------------+-------------+-------------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------
-users           | idx             | hash        |             | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring
-users           | idx1            | btree       |             | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |
-users           | t1_c1_key       | btree       | c1          | f       | t      | u               |                       | c1                        | t    | t           | f          |
-users           | t1_pkey         | btree       | id          | t       | t      | p               |                       | id                        | t    | f           | f          |
-users           | idx4            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |
-users           | idx4            | btree       | id          | f       | t      |                 |                       | id                        | f    | f           | t          |
-users           | idx5            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |
-users           | idx5            | btree       |             | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |
+   table_name   |    index_name   | index_type  | column_name | primary | unique | constraint_type | predicate             |   expression              | desc | nulls_first | nulls_last | comment   | options
+----------------+-----------------+-------------+-------------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+-----------
+users           | idx             | hash        |             | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |
+users           | idx1            | btree       |             | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |
+users           | t1_c1_key       | btree       | c1          | f       | t      | u               |                       | c1                        | t    | t           | f          |           |
+users           | t1_pkey         | btree       | id          | t       | t      | p               |                       | id                        | t    | f           | f          |           |
+users           | idx4            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |           |
+users           | idx4            | btree       | id          | f       | t      |                 |                       | id                        | f    | f           | t          |           |
+users           | idx5            | btree       | c1          | f       | t      |                 |                       | c1                        | f    | f           | f          |           |
+users           | idx5            | btree       |             | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |
+users           | idx6            | brin        | c1          | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}
 `))
 				m.noFKs()
 				m.noChecks()
@@ -165,6 +166,7 @@ users           | idx5            | btree       |             | f       | t
 					{Name: "t1_c1_key", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}, &ConType{T: "u"}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1], Desc: true, Attrs: []schema.Attr{&IndexColumnProperty{NullsFirst: true}}}}},
 					{Name: "idx4", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1]}, {SeqNo: 2, C: columns[0], Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}}},
 					{Name: "idx5", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1]}, {SeqNo: 2, X: &schema.RawExpr{X: `coalesce(parent_id, 0)`}}}},
+					{Name: "idx6", Unique: true, Table: t, Attrs: []schema.Attr{&IndexType{T: "brin"}, &IndexStorageParams{AutoSummarize: true, PagesPerRange: 2}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[1]}}},
 				}
 				pk := &schema.Index{
 					Name:   "t1_pkey",
@@ -592,7 +594,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"}))
+		WillReturnRows(sqlmock.NewRows([]string{"table_name", "index_name", "column_name", "primary", "unique", "constraint_type", "predicate", "expression", "options"}))
 }
 
 func (m mock) noFKs() {
diff --git a/sql/postgres/migrate_test.go b/sql/postgres/migrate_test.go
index cc39e46b..a042c605 100644
--- a/sql/postgres/migrate_test.go
+++ b/sql/postgres/migrate_test.go
@@ -322,6 +322,18 @@ func TestPlanChanges(t *testing.T) {
 									},
 								},
 							},
+							&schema.AddIndex{
+								I: &schema.Index{
+									Name: "id_brin",
+									Parts: []*schema.IndexPart{
+										{C: users.Columns[0], Desc: true},
+									},
+									Attrs: []schema.Attr{
+										&IndexType{T: IndexTypeBRIN},
+										&IndexStorageParams{PagesPerRange: 2},
+									},
+								},
+							},
 							&schema.AddCheck{
 								C: &schema.Check{Name: "name_not_empty", Expr: `("name" <> '')`},
 							},
@@ -348,6 +360,10 @@ func TestPlanChanges(t *testing.T) {
 						Cmd:     `CREATE INDEX "id_key" ON "users" ("id" DESC) WHERE success`,
 						Reverse: `DROP INDEX "id_key"`,
 					},
+					{
+						Cmd:     `CREATE INDEX "id_brin" ON "users" USING BRIN ("id" DESC) WITH (pages_per_range = 2)`,
+						Reverse: `DROP INDEX "id_brin"`,
+					},
 					{
 						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 56a1fbbb..f8b752a4 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -285,6 +285,29 @@ table "t" {
 	})
 }
 
+func TestUnmarshalSpec_BRINIndex(t *testing.T) {
+	f := `
+schema "s" {}
+table "t" {
+	schema = schema.s
+	column "c" {
+		type = int
+	}
+	index "i" {
+		type = BRIN
+		columns = [column.c]
+		page_per_range = 2
+	}
+}
+`
+	var s schema.Schema
+	err := UnmarshalHCL([]byte(f), &s)
+	require.NoError(t, err)
+	idx := s.Tables[0].Indexes[0]
+	require.Equal(t, IndexTypeBRIN, idx.Attrs[0].(*IndexType).T)
+	require.EqualValues(t, 2, idx.Attrs[1].(*IndexStorageParams).PagesPerRange)
+}
+
 func TestUnmarshalSpec_Partitioned(t *testing.T) {
 	t.Run("Columns", func(t *testing.T) {
 		var (
@@ -485,6 +508,58 @@ schema "test" {
 	require.EqualValues(t, expected, string(buf))
 }
 
+func TestMarshalSpec_BRINIndex(t *testing.T) {
+	s := &schema.Schema{
+		Name: "test",
+		Tables: []*schema.Table{
+			{
+				Name: "users",
+				Columns: []*schema.Column{
+					{
+						Name: "id",
+						Type: &schema.ColumnType{Type: &schema.IntegerType{T: "int"}},
+					},
+				},
+			},
+		},
+	}
+	s.Tables[0].Schema = s
+	s.Tables[0].Schema = s
+	s.Tables[0].Indexes = []*schema.Index{
+		{
+			Name:   "index",
+			Table:  s.Tables[0],
+			Unique: true,
+			Parts: []*schema.IndexPart{
+				{SeqNo: 0, C: s.Tables[0].Columns[0]},
+			},
+			Attrs: []schema.Attr{
+				&IndexType{T: IndexTypeBRIN},
+				&IndexStorageParams{PagesPerRange: 2},
+			},
+		},
+	}
+	buf, err := MarshalSpec(s, hclState)
+	require.NoError(t, err)
+	const expected = `table "users" {
+  schema = schema.test
+  column "id" {
+    null = false
+    type = int
+  }
+  index "index" {
+    unique         = true
+    columns        = [column.id]
+    type           = BRIN
+    page_per_range = 2
+  }
+}
+schema "test" {
+}
+`
+	require.EqualValues(t, expected, string(buf))
+}
+
 func TestUnmarshalSpec_Identity(t *testing.T) {
 	f := `
 schema "s" {}