Repository: ariga/atlas
Pull request #1259 (2022-11-03 13:07:06)

doc/md/guides: my102 guide


Patch:
diff --git a/doc/md/guides/guides.md b/doc/md/guides/guides.md
index 3e43a33d..6b94d45d 100644
--- a/doc/md/guides/guides.md
+++ b/doc/md/guides/guides.md
@@ -24,6 +24,8 @@ Welcome to the Atlas Guides.
 
 [Managing MySQL schemas on RDS with Terraform](mysql/terraform.md)
 
+[Inline REFERENCES clauses (MY102)](mysql/my-102.md)
+
 [Dropping non-virtual columns (DS103)](mysql/ds-103.md)
 
 ### PostgreSQL
diff --git a/doc/md/guides/mysql/my-102.md b/doc/md/guides/mysql/my-102.md
new file mode 100644
index 00000000..4a780c9e
--- /dev/null
+++ b/doc/md/guides/mysql/my-102.md
@@ -0,0 +1,83 @@
+---
+id: mysql-my-102
+title: Detecting inline REFERENCES clauses (MY102)
+slug: /guides/mysql/checks/MY102
+tags: [linting, ci, mysql, foreign-keys]
+---
+
+## Introduction
+
+This document describes in detail the detection of inline `REFERENCES` clauses
+for MySQL.
+
+According to the [MySQL Documentation](https://dev.mysql.com/doc/refman/8.0/en/create-table.html):
+> MySQL parses but ignores "inline REFERENCES specifications" 
+> (as defined in the SQL standard) where the references are 
+> defined as part of the column specification
+
+This means that despite the fact that in many databases users can define
+foreign key constraints inline with the column definition as such:
+
+```sql
+CREATE TABLE pets (
+    // highlight-next-line
+    owner_id int REFERENCES users(id)
+);
+```
+The database will silently ignore the `REFERENCES users(id)` clause.
+
+## Impact
+
+### Referential integrity
+
+REFERENCES clauses are used to define referential constrains (foreign keys)
+that allow application developers to rely on the database to maintain referential integrity.
+
+A developer that successfully applied the `CREATE TABLE` statement above
+may wrongfully believe that the database will reject any inserts of 
+records that refer to non-existing records. As a result, other means of
+validation may not be implemented, which may lead to a situation where 
+referential integrity is lost. 
+
+## Prevention
+
+[![asciicast](https://asciinema.org/a/OaHP3PCigndSZZ63eRA2FsuB5.svg)](https://asciinema.org/a/OaHP3PCigndSZZ63eRA2FsuB5)
+
+Preventing migrations with inline REFERENCES clauses is easy with Atlas's [`migrate lint`](/versioned/lint)
+command. With the `atlas migrate lint` command, users can analyze the migration directory to
+detect potentially dangerous changes to the database schema. This command may be
+incorporated in continuous integration pipelines to enable teams to enforce
+desired policies with regard to schema changes.
+
+When using migrate lint to analyze migrations, users must supply multiple parameters:
+
+* `--dev-url` - a URL to a Dev-database that will be used to simulate the changes and verify their correctness.
+* `--dir` - the URL of the migration directory, by default it is file://migrations, e.g a directory named migrations in the current working directory.
+
+### Changeset detection
+
+When we run the lint command, we need to instruct Atlas on how to decide what set of migration files to analyze.
+Currently, two modes are supported:
+
+* `--git-base <branchName>`: which selects the diff between the provided branch and the current one as the changeset.
+* `--latest <n>` which selects the latest n migration files as the changeset.
+
+### Examples
+
+Analyze all changes relative to the `master` Git branch:
+
+```text
+atlas migrate lint \
+  --dir "file://my/project/migrations" \
+  --dev-url "mysql://root:pass@localhost:3306/dev" \
+  --git-base "master"
+```
+
+Analyze the latest 2 migration files:
+
+```text
+atlas migrate lint \
+  --dir "file://my/project/migrations" \
+  --dev-url "mysql://root:pass@localhost:3306/dev" \
+  --latest 2
+```
\ No newline at end of file
diff --git a/doc/website/sidebars.js b/doc/website/sidebars.js
index 69192339..d49e65cf 100644
--- a/doc/website/sidebars.js
+++ b/doc/website/sidebars.js
@@ -133,6 +133,11 @@ module.exports = {
                     id: 'guides/mysql/terraform-mysql-rds',
                     label: 'Managing with Terraform'
                 },
+                {
+                    type: 'doc',
+                    id: 'guides/mysql/mysql-my-102',
+                    label: 'Inline REFERENCES (MY102)'
+                },
                 {
                     type: 'doc',
                     id: 'guides/mysql/mysql-ds-103',
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index be5b9826..5b039480 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -470,7 +470,7 @@ func ExprValue(expr schema.Expr) (cty.Value, error) {
 }
 
 // FromIndex converts schema.Index to sqlspec.Index.
-func FromIndex(idx *schema.Index, partFns ...func(*schema.IndexPart, *sqlspec.IndexPart)) (*sqlspec.Index, error) {
+func FromIndex(idx *schema.Index, partFns ...func(*schema.Index, *schema.IndexPart, *sqlspec.IndexPart) error) (*sqlspec.Index, error) {
 	spec := &sqlspec.Index{Name: idx.Name, Unique: idx.Unique}
 	convertCommentFromSchema(idx.Attrs, &spec.Extra.Attrs)
 	spec.Parts = make([]*sqlspec.IndexPart, len(idx.Parts))
@@ -491,7 +491,9 @@ func FromIndex(idx *schema.Index, partFns ...func(*schema.IndexPart, *sqlspec.In
 			part.Expr = x.X
 		}
 		for _, f := range partFns {
-			f(p, part)
+			if err := f(idx, p, part); err != nil {
+				return nil, err
+			}
 		}
 		spec.Parts[i] = part
 	}
diff --git a/sql/mysql/sqlspec.go b/sql/mysql/sqlspec.go
index 7ef0a75f..23012c74 100644
--- a/sql/mysql/sqlspec.go
+++ b/sql/mysql/sqlspec.go
@@ -244,10 +244,11 @@ func indexSpec(idx *schema.Index) (*sqlspec.Index, error) {
 	return spec, nil
 }
 
-func partAttr(part *schema.IndexPart, spec *sqlspec.IndexPart) {
+func partAttr(_ *schema.Index, part *schema.IndexPart, spec *sqlspec.IndexPart) error {
 	if p := (SubPart{}); sqlx.Has(part.Attrs, &p) && p.Len > 0 {
 		spec.Extra.Attrs = append(spec.Extra.Attrs, schemahcl.IntAttr("prefix", p.Len))
 	}
+	return nil
 }
 
 // columnSpec converts from a concrete MySQL schema.Column into a sqlspec.Column.
diff --git a/sql/postgres/driver.go b/sql/postgres/driver.go
index eb602d28..511e2652 100644
--- a/sql/postgres/driver.go
+++ b/sql/postgres/driver.go
@@ -383,10 +383,11 @@ const (
 // List of supported index types.
 const (
 	IndexTypeBTree      = "BTREE"
+	IndexTypeBRIN       = "BRIN"
 	IndexTypeHash       = "HASH"
 	IndexTypeGIN        = "GIN"
 	IndexTypeGiST       = "GIST"
-	IndexTypeBRIN       = "BRIN"
+	IndexTypeSPGiST     = "SPGIST"
 	defaultPagePerRange = 128
 )
 
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index a709f935..2f9f927b 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -318,12 +318,15 @@ 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
-			desc, nullsfirst, nullslast                   sql.NullBool
-			column, contype, pred, expr, comment, options sql.NullString
+			uniq, primary, included                                           bool
+			table, name, typ                                                  string
+			desc, nullsfirst, nullslast, opcdefault                           sql.NullBool
+			column, contype, pred, expr, comment, options, opcname, opcparams sql.NullString
 		)
-		if err := rows.Scan(&table, &name, &typ, &column, &included, &primary, &uniq, &contype, &pred, &expr, &desc, &nullsfirst, &nullslast, &comment, &options); err != nil {
+		if err := rows.Scan(
+			&table, &name, &typ, &column, &included, &primary, &uniq, &contype, &pred, &expr, &desc,
+			&nullsfirst, &nullslast, &comment, &options, &opcname, &opcdefault, &opcparams,
+		); err != nil {
 			return fmt.Errorf("postgres: scanning indexes for schema %q: %w", s.Name, err)
 		}
 		t, ok := s.Table(table)
@@ -395,10 +398,34 @@ func (i *inspect) addIndexes(s *schema.Schema, rows *sql.Rows) error {
 		default:
 			return fmt.Errorf("postgres: invalid part for index %q", idx.Name)
 		}
+		if err := mayAppendOps(part, opcname.String, opcparams.String, opcdefault.Bool); err != nil {
+			return err
+		}
 	}
 	return nil
 }
 
+// mayAppendOps appends an operator_class attribute to the part in case it is not the default.
+func mayAppendOps(part *schema.IndexPart, name string, params string, defaults bool) error {
+	if name == "" || defaults && params == "" {
+		return nil
+	}
+	op := &IndexOpClass{Name: name, Default: defaults}
+	switch {
+	case len(params) == 0:
+	case len(params) < 2, params[0] != '{', params[len(params)-1] != '}':
+		return fmt.Errorf("postgres: unexpected operator class parameters format: %q", params)
+	default:
+		for _, e := range strings.Split(params[1:len(params)-1], ",") {
+			if kv := strings.Split(strings.TrimSpace(e), "="); len(kv) == 2 {
+				op.Params = append(op.Params, struct{ N, V string }{N: kv[0], V: kv[1]})
+			}
+		}
+	}
+	part.Attrs = append(part.Attrs, op)
+	return nil
+}
+
 // partitions builds the partition each table in the schema.
 func (i *inspect) partitions(s *schema.Schema) error {
 	for _, t := range s.Tables {
@@ -779,8 +806,17 @@ type (
 		Columns []*schema.Column
 	}
 
-	// Concurrently describes the CONCURRENTLY clause to instruct Postgres
-	// to build or drop the index concurrently without blocking the current table.
+	// IndexOpClass describers operator class of the index part.
+	// https://www.postgresql.org/docs/current/indexes-opclass.html.
+	IndexOpClass struct {
+		schema.Attr
+		Name    string                  // Name of the operator class.
+		Default bool                    // If it is the default operator class.
+		Params  []struct{ N, V string } // Optional parameters.
+	}
+
+	// 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
 	Concurrently struct {
 		schema.Attr
@@ -1072,11 +1108,14 @@ SELECT
 	c.contype AS constraint_type,
 	pg_get_expr(idx.indpred, idx.indrelid) AS predicate,
 	pg_get_indexdef(idx.indexrelid, idx.ord, false) AS expression,
-	pg_index_column_has_property(idx.indexrelid, idx.ord, 'desc') AS desc,
+	pg_index_column_has_property(idx.indexrelid, idx.ord, 'desc') AS isdesc,
 	pg_index_column_has_property(idx.indexrelid, idx.ord, 'nulls_first') AS nulls_first,
 	pg_index_column_has_property(idx.indexrelid, idx.ord, 'nulls_last') AS nulls_last,
 	obj_description(i.oid, 'pg_class') AS comment,
-	i.reloptions AS options
+	i.reloptions AS options,
+	op.opcname AS opclass_name,
+	op.opcdefault AS opclass_default,
+	a2.attoptions AS opclass_params
 FROM
 	(
 		select
@@ -1091,6 +1130,8 @@ FROM
 	LEFT JOIN pg_constraint c ON idx.indexrelid = c.conindid
 	LEFT JOIN pg_attribute a ON (a.attrelid, a.attnum) = (idx.indrelid, idx.key)
 	JOIN pg_am am ON am.oid = i.relam
+	LEFT JOIN pg_opclass op ON op.oid = idx.indclass[idx.ord-1]
+	LEFT JOIN pg_attribute a2 ON (a2.attrelid, a2.attnum) = (idx.indexrelid, idx.ord)
 WHERE
 	n.nspname = $1
 	AND t.relname IN (%s)
diff --git a/sql/postgres/internal/postgresop/postgresop.go b/sql/postgres/internal/postgresop/postgresop.go
new file mode 100644
index 00000000..3b58859b
--- /dev/null
+++ b/sql/postgres/internal/postgresop/postgresop.go
@@ -0,0 +1,194 @@
+// Copyright 2021-present The Atlas Authors. All rights reserved.
+// This source code is licensed under the Apache 2.0 license found
+// in the LICENSE file in the root directory of this source tree.
+
+package postgresop
+
+// Class describes an index operator class.
+type Class struct {
+	Name    string // operator class name
+	Method  string // index method
+	Type    string // indexed data type
+	Default bool   // default to the type/method above
+}
+
+// Classes defined in latest PostgreSQL version (15).
+var Classes = []*Class{
+	{Name: "bit_minmax_ops", Method: "BRIN", Type: "bit", Default: true},
+	{Name: "box_inclusion_ops", Method: "BRIN", Type: "box", Default: true},
+	{Name: "bpchar_bloom_ops", Method: "BRIN", Type: "character", Default: false},
+	{Name: "bpchar_minmax_ops", Method: "BRIN", Type: "character", Default: true},
+	{Name: "bytea_bloom_ops", Method: "BRIN", Type: "bytea", Default: false},
+	{Name: "bytea_minmax_ops", Method: "BRIN", Type: "bytea", Default: true},
+	{Name: "char_bloom_ops", Method: "BRIN", Type: "char", Default: false},
+	{Name: "char_minmax_ops", Method: "BRIN", Type: "char", Default: true},
+	{Name: "date_bloom_ops", Method: "BRIN", Type: "date", Default: false},
+	{Name: "date_minmax_multi_ops", Method: "BRIN", Type: "date", Default: false},
+	{Name: "date_minmax_ops", Method: "BRIN", Type: "date", Default: true},
+	{Name: "float4_bloom_ops", Method: "BRIN", Type: "real", Default: false},
+	{Name: "float4_minmax_multi_ops", Method: "BRIN", Type: "real", Default: false},
+	{Name: "float4_minmax_ops", Method: "BRIN", Type: "real", Default: true},
+	{Name: "float8_bloom_ops", Method: "BRIN", Type: "double precision", Default: false},
+	{Name: "float8_minmax_multi_ops", Method: "BRIN", Type: "double precision", Default: false},
+	{Name: "float8_minmax_ops", Method: "BRIN", Type: "double precision", Default: true},
+	{Name: "inet_bloom_ops", Method: "BRIN", Type: "inet", Default: false},
+	{Name: "inet_inclusion_ops", Method: "BRIN", Type: "inet", Default: true},
+	{Name: "inet_minmax_multi_ops", Method: "BRIN", Type: "inet", Default: false},
+	{Name: "inet_minmax_ops", Method: "BRIN", Type: "inet", Default: false},
+	{Name: "int2_bloom_ops", Method: "BRIN", Type: "smallint", Default: false},
+	{Name: "int2_minmax_multi_ops", Method: "BRIN", Type: "smallint", Default: false},
+	{Name: "int2_minmax_ops", Method: "BRIN", Type: "smallint", Default: true},
+	{Name: "int4_bloom_ops", Method: "BRIN", Type: "integer", Default: false},
+	{Name: "int4_minmax_multi_ops", Method: "BRIN", Type: "integer", Default: false},
+	{Name: "int4_minmax_ops", Method: "BRIN", Type: "integer", Default: true},
+	{Name: "int8_bloom_ops", Method: "BRIN", Type: "bigint", Default: false},
+	{Name: "int8_minmax_multi_ops", Method: "BRIN", Type: "bigint", Default: false},
+	{Name: "int8_minmax_ops", Method: "BRIN", Type: "bigint", Default: true},
+	{Name: "interval_bloom_ops", Method: "BRIN", Type: "interval", Default: false},
+	{Name: "interval_minmax_multi_ops", Method: "BRIN", Type: "interval", Default: false},
+	{Name: "interval_minmax_ops", Method: "BRIN", Type: "interval", Default: true},
+	{Name: "macaddr8_bloom_ops", Method: "BRIN", Type: "macaddr8", Default: false},
+	{Name: "macaddr8_minmax_multi_ops", Method: "BRIN", Type: "macaddr8", Default: false},
+	{Name: "macaddr8_minmax_ops", Method: "BRIN", Type: "macaddr8", Default: true},
+	{Name: "macaddr_bloom_ops", Method: "BRIN", Type: "macaddr", Default: false},
+	{Name: "macaddr_minmax_multi_ops", Method: "BRIN", Type: "macaddr", Default: false},
+	{Name: "macaddr_minmax_ops", Method: "BRIN", Type: "macaddr", Default: true},
+	{Name: "name_bloom_ops", Method: "BRIN", Type: "name", Default: false},
+	{Name: "name_minmax_ops", Method: "BRIN", Type: "name", Default: true},
+	{Name: "numeric_bloom_ops", Method: "BRIN", Type: "numeric", Default: false},
+	{Name: "numeric_minmax_multi_ops", Method: "BRIN", Type: "numeric", Default: false},
+	{Name: "numeric_minmax_ops", Method: "BRIN", Type: "numeric", Default: true},
+	{Name: "oid_bloom_ops", Method: "BRIN", Type: "oid", Default: false},
+	{Name: "oid_minmax_multi_ops", Method: "BRIN", Type: "oid", Default: false},
+	{Name: "oid_minmax_ops", Method: "BRIN", Type: "oid", Default: true},
+	{Name: "pg_lsn_bloom_ops", Method: "BRIN", Type: "pg_lsn", Default: false},
+	{Name: "pg_lsn_minmax_multi_ops", Method: "BRIN", Type: "pg_lsn", Default: false},
+	{Name: "pg_lsn_minmax_ops", Method: "BRIN", Type: "pg_lsn", Default: true},
+	{Name: "range_inclusion_ops", Method: "BRIN", Type: "anyrange", Default: true},
+	{Name: "text_bloom_ops", Method: "BRIN", Type: "text", Default: false},
+	{Name: "text_minmax_ops", Method: "BRIN", Type: "text", Default: true},
+	{Name: "tid_bloom_ops", Method: "BRIN", Type: "tid", Default: false},
+	{Name: "tid_minmax_multi_ops", Method: "BRIN", Type: "tid", Default: false},
+	{Name: "tid_minmax_ops", Method: "BRIN", Type: "tid", Default: true},
+	{Name: "time_bloom_ops", Method: "BRIN", Type: "time without time zone", Default: false},
+	{Name: "time_minmax_multi_ops", Method: "BRIN", Type: "time without time zone", Default: false},
+	{Name: "time_minmax_ops", Method: "BRIN", Type: "time without time zone", Default: true},
+	{Name: "timestamp_bloom_ops", Method: "BRIN", Type: "timestamp without time zone", Default: false},
+	{Name: "timestamp_minmax_multi_ops", Method: "BRIN", Type: "timestamp without time zone", Default: false},
+	{Name: "timestamp_minmax_ops", Method: "BRIN", Type: "timestamp without time zone", Default: true},
+	{Name: "timestamptz_bloom_ops", Method: "BRIN", Type: "timestamp with time zone", Default: false},
+	{Name: "timestamptz_minmax_multi_ops", Method: "BRIN", Type: "timestamp with time zone", Default: false},
+	{Name: "timestamptz_minmax_ops", Method: "BRIN", Type: "timestamp with time zone", Default: true},
+	{Name: "timetz_bloom_ops", Method: "BRIN", Type: "time with time zone", Default: false},
+	{Name: "timetz_minmax_multi_ops", Method: "BRIN", Type: "time with time zone", Default: false},
+	{Name: "timetz_minmax_ops", Method: "BRIN", Type: "time with time zone", Default: true},
+	{Name: "uuid_bloom_ops", Method: "BRIN", Type: "uuid", Default: false},
+	{Name: "uuid_minmax_multi_ops", Method: "BRIN", Type: "uuid", Default: false},
+	{Name: "uuid_minmax_ops", Method: "BRIN", Type: "uuid", Default: true},
+	{Name: "varbit_minmax_ops", Method: "BRIN", Type: "bit varying", Default: true},
+	{Name: "array_ops", Method: "BTREE", Type: "anyarray", Default: true},
+	{Name: "bit_ops", Method: "BTREE", Type: "bit", Default: true},
+	{Name: "bool_ops", Method: "BTREE", Type: "boolean", Default: true},
+	{Name: "bpchar_ops", Method: "BTREE", Type: "character", Default: true},
+	{Name: "bpchar_pattern_ops", Method: "BTREE", Type: "character", Default: false},
+	{Name: "bytea_ops", Method: "BTREE", Type: "bytea", Default: true},
+	{Name: "char_ops", Method: "BTREE", Type: "char", Default: true},
+	{Name: "cidr_ops", Method: "BTREE", Type: "inet", Default: false},
+	{Name: "date_ops", Method: "BTREE", Type: "date", Default: true},
+	{Name: "enum_ops", Method: "BTREE", Type: "anyenum", Default: true},
+	{Name: "float4_ops", Method: "BTREE", Type: "real", Default: true},
+	{Name: "float8_ops", Method: "BTREE", Type: "double precision", Default: true},
+	{Name: "inet_ops", Method: "BTREE", Type: "inet", Default: true},
+	{Name: "int2_ops", Method: "BTREE", Type: "smallint", Default: true},
+	{Name: "int4_ops", Method: "BTREE", Type: "integer", Default: true},
+	{Name: "int8_ops", Method: "BTREE", Type: "bigint", Default: true},
+	{Name: "interval_ops", Method: "BTREE", Type: "interval", Default: true},
+	{Name: "jsonb_ops", Method: "BTREE", Type: "jsonb", Default: true},
+	{Name: "macaddr8_ops", Method: "BTREE", Type: "macaddr8", Default: true},
+	{Name: "macaddr_ops", Method: "BTREE", Type: "macaddr", Default: true},
+	{Name: "money_ops", Method: "BTREE", Type: "money", Default: true},
+	{Name: "multirange_ops", Method: "BTREE", Type: "anymultirange", Default: true},
+	{Name: "name_ops", Method: "BTREE", Type: "name", Default: true},
+	{Name: "numeric_ops", Method: "BTREE", Type: "numeric", Default: true},
+	{Name: "oid_ops", Method: "BTREE", Type: "oid", Default: true},
+	{Name: "oidvector_ops", Method: "BTREE", Type: "oidvector", Default: true},
+	{Name: "pg_lsn_ops", Method: "BTREE", Type: "pg_lsn", Default: true},
+	{Name: "range_ops", Method: "BTREE", Type: "anyrange", Default: true},
+	{Name: "record_image_ops", Method: "BTREE", Type: "record", Default: false},
+	{Name: "record_ops", Method: "BTREE", Type: "record", Default: true},
+	{Name: "text_ops", Method: "BTREE", Type: "text", Default: true},
+	{Name: "text_pattern_ops", Method: "BTREE", Type: "text", Default: false},
+	{Name: "tid_ops", Method: "BTREE", Type: "tid", Default: true},
+	{Name: "time_ops", Method: "BTREE", Type: "time without time zone", Default: true},
+	{Name: "timestamp_ops", Method: "BTREE", Type: "timestamp without time zone", Default: true},
+	{Name: "timestamptz_ops", Method: "BTREE", Type: "timestamp with time zone", Default: true},
+	{Name: "timetz_ops", Method: "BTREE", Type: "time with time zone", Default: true},
+	{Name: "tsquery_ops", Method: "BTREE", Type: "tsquery", Default: true},
+	{Name: "tsvector_ops", Method: "BTREE", Type: "tsvector", Default: true},
+	{Name: "uuid_ops", Method: "BTREE", Type: "uuid", Default: true},
+	{Name: "varbit_ops", Method: "BTREE", Type: "bit varying", Default: true},
+	{Name: "varchar_ops", Method: "BTREE", Type: "text", Default: false},
+	{Name: "varchar_pattern_ops", Method: "BTREE", Type: "text", Default: false},
+	{Name: "xid8_ops", Method: "BTREE", Type: "xid8", Default: true},
+	{Name: "array_ops", Method: "GIN", Type: "anyarray", Default: true},
+	{Name: "jsonb_ops", Method: "GIN", Type: "jsonb", Default: true},
+	{Name: "jsonb_path_ops", Method: "GIN", Type: "jsonb", Default: false},
+	{Name: "tsvector_ops", Method: "GIN", Type: "tsvector", Default: true},
+	{Name: "box_ops", Method: "GIST", Type: "box", Default: true},
+	{Name: "circle_ops", Method: "GIST", Type: "circle", Default: true},
+	{Name: "inet_ops", Method: "GIST", Type: "inet", Default: false},
+	{Name: "multirange_ops", Method: "GIST", Type: "anymultirange", Default: true},
+	{Name: "point_ops", Method: "GIST", Type: "point", Default: true},
+	{Name: "poly_ops", Method: "GIST", Type: "polygon", Default: true},
+	{Name: "range_ops", Method: "GIST", Type: "anyrange", Default: true},
+	{Name: "tsquery_ops", Method: "GIST", Type: "tsquery", Default: true},
+	{Name: "tsvector_ops", Method: "GIST", Type: "tsvector", Default: true},
+	{Name: "aclitem_ops", Method: "HASH", Type: "aclitem", Default: true},
+	{Name: "array_ops", Method: "HASH", Type: "anyarray", Default: true},
+	{Name: "bool_ops", Method: "HASH", Type: "boolean", Default: true},
+	{Name: "bpchar_ops", Method: "HASH", Type: "character", Default: true},
+	{Name: "bpchar_pattern_ops", Method: "HASH", Type: "character", Default: false},
+	{Name: "bytea_ops", Method: "HASH", Type: "bytea", Default: true},
+	{Name: "char_ops", Method: "HASH", Type: "char", Default: true},
+	{Name: "cid_ops", Method: "HASH", Type: "cid", Default: true},
+	{Name: "cidr_ops", Method: "HASH", Type: "inet", Default: false},
+	{Name: "date_ops", Method: "HASH", Type: "date", Default: true},
+	{Name: "enum_ops", Method: "HASH", Type: "anyenum", Default: true},
+	{Name: "float4_ops", Method: "HASH", Type: "real", Default: true},
+	{Name: "float8_ops", Method: "HASH", Type: "double precision", Default: true},
+	{Name: "inet_ops", Method: "HASH", Type: "inet", Default: true},
+	{Name: "int2_ops", Method: "HASH", Type: "smallint", Default: true},
+	{Name: "int4_ops", Method: "HASH", Type: "integer", Default: true},
+	{Name: "int8_ops", Method: "HASH", Type: "bigint", Default: true},
+	{Name: "interval_ops", Method: "HASH", Type: "interval", Default: true},
+	{Name: "jsonb_ops", Method: "HASH", Type: "jsonb", Default: true},
+	{Name: "macaddr8_ops", Method: "HASH", Type: "macaddr8", Default: true},
+	{Name: "macaddr_ops", Method: "HASH", Type: "macaddr", Default: true},
+	{Name: "multirange_ops", Method: "HASH", Type: "anymultirange", Default: true},
+	{Name: "name_ops", Method: "HASH", Type: "name", Default: true},
+	{Name: "numeric_ops", Method: "HASH", Type: "numeric", Default: true},
+	{Name: "oid_ops", Method: "HASH", Type: "oid", Default: true},
+	{Name: "oidvector_ops", Method: "HASH", Type: "oidvector", Default: true},
+	{Name: "pg_lsn_ops", Method: "HASH", Type: "pg_lsn", Default: true},
+	{Name: "range_ops", Method: "HASH", Type: "anyrange", Default: true},
+	{Name: "record_ops", Method: "HASH", Type: "record", Default: true},
+	{Name: "text_ops", Method: "HASH", Type: "text", Default: true},
+	{Name: "text_pattern_ops", Method: "HASH", Type: "text", Default: false},
+	{Name: "tid_ops", Method: "HASH", Type: "tid", Default: true},
+	{Name: "time_ops", Method: "HASH", Type: "time without time zone", Default: true},
+	{Name: "timestamp_ops", Method: "HASH", Type: "timestamp without time zone", Default: true},
+	{Name: "timestamptz_ops", Method: "HASH", Type: "timestamp with time zone", Default: true},
+	{Name: "timetz_ops", Method: "HASH", Type: "time with time zone", Default: true},
+	{Name: "uuid_ops", Method: "HASH", Type: "uuid", Default: true},
+	{Name: "varchar_ops", Method: "HASH", Type: "text", Default: false},
+	{Name: "varchar_pattern_ops", Method: "HASH", Type: "text", Default: false},
+	{Name: "xid8_ops", Method: "HASH", Type: "xid8", Default: true},
+	{Name: "xid_ops", Method: "HASH", Type: "xid", Default: true},
+	{Name: "box_ops", Method: "SPGIST", Type: "box", Default: true},
+	{Name: "inet_ops", Method: "SPGIST", Type: "inet", Default: true},
+	{Name: "kd_point_ops", Method: "SPGIST", Type: "point", Default: false},
+	{Name: "poly_ops", Method: "SPGIST", Type: "polygon", Default: true},
+	{Name: "quad_point_ops", Method: "SPGIST", Type: "point", Default: true},
+	{Name: "range_ops", Method: "SPGIST", Type: "anyrange", Default: true},
+	{Name: "text_ops", Method: "SPGIST", Type: "text", Default: true},
+}
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index f417cea6..4131a9c6 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -9,10 +9,12 @@ import (
 	"reflect"
 	"strconv"
 	"strings"
+	"sync"
 
 	"ariga.io/atlas/schemahcl"
 	"ariga.io/atlas/sql/internal/specutil"
 	"ariga.io/atlas/sql/internal/sqlx"
+	"ariga.io/atlas/sql/postgres/internal/postgresop"
 	"ariga.io/atlas/sql/schema"
 	"ariga.io/atlas/sql/sqlspec"
 
@@ -111,12 +113,18 @@ func MarshalSpec(v any, marshaler schemahcl.Marshaler) ([]byte, error) {
 var (
 	hclState = schemahcl.New(
 		schemahcl.WithTypes(TypeRegistry.Specs()),
-		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeHash, IndexTypeGIN, IndexTypeGiST, IndexTypeBRIN),
+		schemahcl.WithScopedEnums("table.index.type", IndexTypeBTree, IndexTypeBRIN, IndexTypeHash, IndexTypeGIN, IndexTypeGiST, IndexTypeSPGiST),
 		schemahcl.WithScopedEnums("table.partition.type", PartitionTypeRange, PartitionTypeList, PartitionTypeHash),
 		schemahcl.WithScopedEnums("table.column.identity.generated", GeneratedTypeAlways, GeneratedTypeByDefault),
 		schemahcl.WithScopedEnums("table.column.as.type", "STORED"),
 		schemahcl.WithScopedEnums("table.foreign_key.on_update", specutil.ReferenceVars...),
 		schemahcl.WithScopedEnums("table.foreign_key.on_delete", specutil.ReferenceVars...),
+		schemahcl.WithScopedEnums("table.index.on.ops", func() (ops []string) {
+			for _, op := range postgresop.Classes {
+				ops = append(ops, op.Name)
+			}
+			return ops
+		}()...),
 	)
 	// MarshalHCL marshals v into an Atlas HCL DDL document.
 	MarshalHCL = schemahcl.MarshalerFunc(func(v any) ([]byte, error) {
@@ -492,7 +500,7 @@ func tableSpec(table *schema.Table) (*sqlspec.Table, error) {
 }
 
 func indexSpec(idx *schema.Index) (*sqlspec.Index, error) {
-	spec, err := specutil.FromIndex(idx)
+	spec, err := specutil.FromIndex(idx, partAttr)
 	if err != nil {
 		return nil, err
 	}
@@ -516,6 +524,38 @@ func indexSpec(idx *schema.Index) (*sqlspec.Index, error) {
 	return spec, nil
 }
 
+func partAttr(idx *schema.Index, part *schema.IndexPart, spec *sqlspec.IndexPart) error {
+	var op IndexOpClass
+	if !sqlx.Has(part.Attrs, &op) {
+		return nil
+	}
+	if len(op.Params) > 0 {
+		var kv []string
+		for _, e := range op.Params {
+			kv = append(kv, fmt.Sprintf("%s='%s'", e.N, e.V))
+		}
+		spec.Extra.Attrs = append(
+			spec.Extra.Attrs,
+			schemahcl.RawAttr("ops", fmt.Sprintf("%s(%s)", op.Name, strings.Join(kv, ", "))),
+		)
+		return nil
+	}
+	var it IndexType
+	// The type of the key need to be known in order to check
+	// if it is the default operator class.
+	if part.X != nil || !sqlx.Has(idx.Attrs, &it) || op.Default {
+		return nil
+	}
+	d, err := defaultOp(op.Name, it.T, part.C.Type.Type)
+	if err != nil {
+		return fmt.Errorf("postgres: checking if operator_class is default: %w", err)
+	}
+	if !d {
+		spec.Extra.Attrs = append(spec.Extra.Attrs, specutil.VarAttr("ops", op.Name))
+	}
+	return nil
+}
+
 // columnSpec converts from a concrete Postgres schema.Column into a sqlspec.Column.
 func columnSpec(c *schema.Column, _ *schema.Table) (*sqlspec.Column, error) {
 	s, err := specutil.FromColumn(c, columnTypeSpec)
@@ -719,3 +759,36 @@ func formatTime() schemahcl.TypeSpecOption {
 
 // generatedType returns the default and only type for a generated column.
 func generatedType(string) string { return "STORED" }
+
+var (
+	opsOnce    sync.Once
+	defaultOps map[postgresop.Class]bool
+)
+
+// defaultOp reports if the given operator class name is the default for the index part.
+func defaultOp(name, method string, typ schema.Type) (bool, error) {
+	opsOnce.Do(func() {
+		defaultOps = make(map[postgresop.Class]bool, len(postgresop.Classes))
+		for _, op := range postgresop.Classes {
+			if op.Default {
+				defaultOps[postgresop.Class{Name: op.Name, Method: op.Method, Type: op.Type}] = true
+			}
+		}
+	})
+	var (
+		t   string
+		err error
+	)
+	switch typ := typ.(type) {
+	case *schema.EnumType:
+		t = "anyenum"
+	case *ArrayType:
+		t = "anyarray"
+	default:
+		t, err = FormatType(typ)
+		if err != nil {
+			return false, err
+		}
+	}
+	return defaultOps[postgresop.Class{Name: name, Method: strings.ToUpper(method), Type: t}], nil
+}


Test patch:
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index 75e83be3..0bedd59f 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -155,26 +155,28 @@ table_name | column_name |      data_type      | formatted |  is_nullable |
 users      | id          | bigint              | int8      |  NO          |                                 |                          |                64 |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |    20
 users      | c1          | smallint            | int2      |  NO          |                                 |                          |                16 |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |    21
 users      | parent_id   | bigint              | int8      |  YES         |                                 |                          |                64 |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |    22
+users      | ts          | tsvector            | tsvector  |  NO          |                                 |                          |                   |                    |             0 |               |                    |                | NO          |                |                    |                  |                     |                       |         | b       |         |         |    23
 `))
 				m.ExpectQuery(queryIndexes).
 					WithArgs("public", "users").
 					WillReturnRows(sqltest.Rows(`
-   table_name   |    index_name   | index_type  | column_name | included | primary | unique | constraint_type | predicate             |   expression              | desc | nulls_first | nulls_last | comment   | options
-----------------+-----------------+-------------+-------------+----------+---------+--------+-----------------+-----------------------+---------------------------+------+-------------+------------+-----------+-----------
-users           | idx             | hash        |             | f        | f       | f      |                 |                       | "left"((c11)::text, 100)  | t    | t           | f          | boring    |
-users           | idx1            | btree       |             | f        | f       | f      |                 | (id <> NULL::integer) | "left"((c11)::text, 100)  | t    | t           | f          |           |
-users           | t1_c1_key       | btree       | c1          | f        | f       | t      | u               |                       | c1                        | t    | t           | f          |           |
-users           | t1_pkey         | btree       | id          | f        | t       | t      | p               |                       | id                        | t    | f           | f          |           |
-users           | idx4            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |
-users           | idx4            | btree       | id          | f        | f       | t      |                 |                       | id                        | f    | f           | t          |           |
-users           | idx5            | btree       | c1          | f        | f       | t      |                 |                       | c1                        | f    | f           | f          |           |
-users           | idx5            | btree       |             | f        | f       | t      |                 |                       | coalesce(parent_id, 0)    | f    | f           | f          |           |
-users           | idx6            | brin        | c1          | f        | f       | t      |                 |                       |                           | f    | f           | f          |           | {autosummarize=true,pages_per_range=2}
-users           | idx2            | btree       |             | f        | f       | f      |                 |                       | ((c * 2))                 | f    | f           | t          |           | 
-users           | idx2            | btree       | c1          | f        | f       | f      |                 |                       | c                         | f    | f           | t          |           | 
-users           | idx2            | btree       | id          | f        | f       | f      |                 |                       | d                         | f    | f           | t          |           | 
-users           | idx2            | btree       | c1          | t        | f       | f      |                 |                       | c                         |      |             |            |           | 
-users           | idx2            | btree       | parent_id   | t        | f       | f      |                 |                       | d                         |      |             |            |           |
+   table_name   |    index_name   | index_type  | column_name | included | primary | unique | constraint_type | 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      | u               |                       | c1                        | t    | t           | f          |           |                                       |     int4_ops      |        t        |
+users           | t1_pkey         | btree       | id          | f        | t       | t      | 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}
 `))
 				m.noFKs()
 				m.noChecks()
@@ -186,6 +188,7 @@ users           | idx2            | btree       | parent_id   | t        | f
 					{Name: "id", Type: &schema.ColumnType{Raw: "bigint", Type: &schema.IntegerType{T: "bigint"}}},
 					{Name: "c1", Type: &schema.ColumnType{Raw: "smallint", Type: &schema.IntegerType{T: "smallint"}}},
 					{Name: "parent_id", Type: &schema.ColumnType{Raw: "bigint", Null: true, Type: &schema.IntegerType{T: "bigint"}}},
+					{Name: "ts", Type: &schema.ColumnType{Raw: "tsvector", Type: &TextSearchType{T: "tsvector"}}},
 				}
 				indexes := []*schema.Index{
 					{Name: "idx", Table: t, Attrs: []schema.Attr{&IndexType{T: "hash"}, &schema.Comment{Text: "boring"}}, Parts: []*schema.IndexPart{{SeqNo: 1, X: &schema.RawExpr{X: `"left"((c11)::text, 100)`}, Desc: true, Attrs: []schema.Attr{&IndexColumnProperty{NullsFirst: true}}}}},
@@ -194,7 +197,8 @@ users           | idx2            | btree       | parent_id   | t        | f
 					{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]}}},
-					{Name: "idx2", Unique: false, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}, &IndexInclude{Columns: columns[1:]}}, Parts: []*schema.IndexPart{{SeqNo: 1, X: &schema.RawExpr{X: `((c * 2))`}, Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}, {SeqNo: 2, C: columns[1], Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}, {SeqNo: 3, C: columns[0], Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}}},
+					{Name: "idx2", Unique: false, Table: t, Attrs: []schema.Attr{&IndexType{T: "btree"}, &IndexInclude{Columns: columns[1:3]}}, Parts: []*schema.IndexPart{{SeqNo: 1, X: &schema.RawExpr{X: `((c * 2))`}, Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}, {SeqNo: 2, C: columns[1], Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}, {SeqNo: 3, C: columns[0], Attrs: []schema.Attr{&IndexColumnProperty{NullsLast: true}}}}},
+					{Name: "tsx", Unique: false, Table: t, Attrs: []schema.Attr{&IndexType{T: "gist"}}, Parts: []*schema.IndexPart{{SeqNo: 1, C: columns[3], Attrs: []schema.Attr{&IndexOpClass{Name: "tsvector_ops", Params: []struct{ N, V string }{{N: "siglen", V: "1"}}}}}}},
 				}
 				pk := &schema.Index{
 					Name:   "t1_pkey",
@@ -204,7 +208,8 @@ users           | idx2            | btree       | parent_id   | t        | f
 					Parts:  []*schema.IndexPart{{SeqNo: 1, C: columns[0], Desc: true}},
 				}
 				columns[0].Indexes = append(columns[0].Indexes, pk, indexes[3], indexes[6])
-				columns[1].Indexes = indexes[2:]
+				columns[1].Indexes = indexes[2:7]
+				columns[3].Indexes = indexes[7:]
 				require.EqualValues(columns, t.Columns)
 				require.EqualValues(indexes, t.Indexes)
 				require.EqualValues(pk, t.PrimaryKey)
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index 67ed5771..19492f1d 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -570,6 +570,138 @@ schema "test" {
 	require.EqualValues(t, expected, string(buf))
 }
 
+func TestMarshalSpec_IndexOpClass(t *testing.T) {
+	s := &schema.Schema{
+		Name: "test",
+		Tables: []*schema.Table{
+			{
+				Name: "users",
+				Columns: []*schema.Column{
+					{
+						Name: "a",
+						Type: &schema.ColumnType{Type: &schema.StringType{T: "text"}},
+					},
+					{
+						Name: "b",
+						Type: &schema.ColumnType{Type: &TextSearchType{T: "tsvector"}},
+					},
+				},
+			},
+		},
+	}
+	s.Tables[0].Schema = s
+	s.Tables[0].Schema = s
+	s.Tables[0].Indexes = []*schema.Index{
+		{
+			Name:   "idx0",
+			Table:  s.Tables[0],
+			Unique: true,
+			Parts: []*schema.IndexPart{
+				{SeqNo: 0, C: s.Tables[0].Columns[0], Attrs: []schema.Attr{&IndexOpClass{Name: "text_ops"}}},
+				{SeqNo: 1, C: s.Tables[0].Columns[1], Attrs: []schema.Attr{&IndexOpClass{Name: "tsvector_ops"}}},
+			},
+			Attrs: []schema.Attr{
+				&IndexType{T: IndexTypeBTree},
+			},
+		},
+		{
+			Name:   "idx1",
+			Table:  s.Tables[0],
+			Unique: true,
+			Parts: []*schema.IndexPart{
+				{SeqNo: 0, C: s.Tables[0].Columns[0], Attrs: []schema.Attr{&IndexOpClass{Name: "text_pattern_ops"}}},
+				{SeqNo: 1, C: s.Tables[0].Columns[1], Attrs: []schema.Attr{&IndexOpClass{Name: "tsvector_ops"}}},
+			},
+			Attrs: []schema.Attr{
+				&IndexType{T: IndexTypeBTree},
+			},
+		},
+		{
+			Name:   "idx2",
+			Table:  s.Tables[0],
+			Unique: true,
+			Parts: []*schema.IndexPart{
+				{SeqNo: 0, C: s.Tables[0].Columns[0], Attrs: []schema.Attr{&IndexOpClass{Name: "text_ops"}}},
+			},
+			Attrs: []schema.Attr{
+				&IndexType{T: IndexTypeBTree},
+			},
+		},
+		{
+			Name:   "idx3",
+			Table:  s.Tables[0],
+			Unique: true,
+			Parts: []*schema.IndexPart{
+				{SeqNo: 0, C: s.Tables[0].Columns[0], Attrs: []schema.Attr{&IndexOpClass{Name: "text_pattern_ops"}}},
+			},
+			Attrs: []schema.Attr{
+				&IndexType{T: IndexTypeBTree},
+			},
+		},
+		{
+			Name:   "idx4",
+			Table:  s.Tables[0],
+			Unique: true,
+			Parts: []*schema.IndexPart{
+				{SeqNo: 0, C: s.Tables[0].Columns[1], Attrs: []schema.Attr{&IndexOpClass{Name: "tsvector_ops", Default: true, Params: []struct{ N, V string }{{"siglen", "1"}}}}},
+			},
+			Attrs: []schema.Attr{
+				&IndexType{T: IndexTypeGiST},
+			},
+		},
+	}
+	buf, err := MarshalSpec(s, hclState)
+	require.NoError(t, err)
+	const expected = `table "users" {
+  schema = schema.test
+  column "a" {
+    null = false
+    type = text
+  }
+  column "b" {
+    null = false
+    type = tsvector
+  }
+  index "idx0" {
+    unique  = true
+    columns = [column.a, column.b]
+  }
+  index "idx1" {
+    unique = true
+    on {
+      column = column.a
+      ops    = text_pattern_ops
+    }
+    on {
+      column = column.b
+    }
+  }
+  index "idx2" {
+    unique  = true
+    columns = [column.a]
+  }
+  index "idx3" {
+    unique = true
+    on {
+      column = column.a
+      ops    = text_pattern_ops
+    }
+  }
+  index "idx4" {
+    unique = true
+    type   = GIST
+    on {
+      column = column.b
+      ops    = sql("tsvector_ops(siglen='1')")
+    }
+  }
+}
+schema "test" {
+}
+`
+	require.EqualValues(t, expected, string(buf))
+}
+
 func TestUnmarshalSpec_Identity(t *testing.T) {
 	f := `
 schema "s" {}