Repository: ariga/atlas
Pull request #1260 (2022-11-03 16:38:39)

sql/postgres: support marshaling operator_class of key parts


Patch:
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/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/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" {}