Repository: ariga/atlas
Pull request #2199 (2023-10-22 09:23:29)

sql/postgres: allow extending drivers to support udf types


Patch:
diff --git a/schemahcl/spec.go b/schemahcl/spec.go
index bc4c105b..3cc17fd7 100644
--- a/schemahcl/spec.go
+++ b/schemahcl/spec.go
@@ -91,6 +91,15 @@ type (
 	}
 )
 
+// IsRefTo indicates if the Type is a reference to specific schema type definition.
+func (t *Type) IsRefTo(n string) bool {
+	if !t.IsRef {
+		return false
+	}
+	path, err := (&Ref{V: t.T}).ByType(n)
+	return err == nil && len(path) > 0
+}
+
 // IsRef indicates if the attribute is a reference type.
 func (a *Attr) IsRef() bool {
 	if !a.V.Type().IsCapsuleType() {
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index 4c06540e..7aeda35f 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -169,7 +169,7 @@ func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 			case len(p) == 0:
 				return fmt.Errorf("specutil: empty reference for %s.%s", srcT, v.Name)
 			case p[0].T == typeView:
-				q, n, err := refName(r, typeView)
+				q, n, err := RefName(r, typeView)
 				if err != nil {
 					return fmt.Errorf("specutil: extract view name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
 				}
@@ -181,7 +181,7 @@ func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 				}
 				v.AddDeps(v1)
 			case p[0].T == typeMaterialized:
-				q, n, err := refName(r, typeMaterialized)
+				q, n, err := RefName(r, typeMaterialized)
 				if err != nil {
 					return fmt.Errorf("specutil: extract materialized name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
 				}
@@ -193,7 +193,7 @@ func Scan(r *schema.Realm, doc *ScanDoc, funcs *ScanFuncs) error {
 				}
 				v.AddDeps(v1)
 			case p[0].T == typeTable:
-				q, n, err := refName(r, typeTable)
+				q, n, err := RefName(r, typeTable)
 				if err != nil {
 					return fmt.Errorf("specutil: extract table name from %s.%s.depends_on[%d]: %w", srcT, v.Name, i, err)
 				}
@@ -974,10 +974,11 @@ func findT[T schema.View | schema.Table](sch *schema.Schema, qualifier, name str
 }
 
 func tableName(ref *schemahcl.Ref) (string, string, error) {
-	return refName(ref, typeTable)
+	return RefName(ref, typeTable)
 }
 
-func refName(ref *schemahcl.Ref, typeName string) (qualifier, name string, err error) {
+// RefName returns the qualifier and name from a reference.
+func RefName(ref *schemahcl.Ref, typeName string) (qualifier, name string, err error) {
 	vs, err := ref.ByType(typeName)
 	if err != nil {
 		return "", "", err
diff --git a/sql/internal/sqlx/sqlx.go b/sql/internal/sqlx/sqlx.go
index 2ff85293..270da7f9 100644
--- a/sql/internal/sqlx/sqlx.go
+++ b/sql/internal/sqlx/sqlx.go
@@ -224,7 +224,7 @@ func ValuesEqual(v1, v2 []string) bool {
 // ModeInspectSchema returns the InspectMode or its default.
 func ModeInspectSchema(o *schema.InspectOptions) schema.InspectMode {
 	if o == nil || o.Mode == 0 {
-		return schema.InspectSchemas | schema.InspectTables | schema.InspectViews | schema.InspectFuncs
+		return schema.InspectSchemas | schema.InspectTables | schema.InspectViews | schema.InspectFuncs | schema.InspectTypes
 	}
 	return o.Mode
 }
@@ -232,7 +232,7 @@ func ModeInspectSchema(o *schema.InspectOptions) schema.InspectMode {
 // ModeInspectRealm returns the InspectMode or its default.
 func ModeInspectRealm(o *schema.InspectRealmOption) schema.InspectMode {
 	if o == nil || o.Mode == 0 {
-		return schema.InspectSchemas | schema.InspectTables | schema.InspectViews | schema.InspectFuncs
+		return schema.InspectSchemas | schema.InspectTables | schema.InspectViews | schema.InspectFuncs | schema.InspectTypes
 	}
 	return o.Mode
 }
diff --git a/sql/postgres/convert.go b/sql/postgres/convert.go
index 85696240..628511f2 100644
--- a/sql/postgres/convert.go
+++ b/sql/postgres/convert.go
@@ -37,6 +37,11 @@ func FormatType(t schema.Type) (string, error) {
 		f = strings.ToLower(t.T)
 	case *CurrencyType:
 		f = strings.ToLower(t.T)
+	case *DomainType:
+		if t.T == "" {
+			return "", errors.New("postgres: missing domain type name")
+		}
+		f = t.T
 	case *schema.EnumType:
 		if t.T == "" {
 			return "", errors.New("postgres: missing enum type name")
@@ -295,8 +300,7 @@ func columnType(c *columnDesc) (schema.Type, error) {
 		// https://postgresql.org/docs/current/catalog-pg-type.html
 		typ = newEnumType(c.fmtype, c.typid)
 	case "d":
-		// Use user-defined for domain types as we do not
-		// support their creation at this stage.
+		// Use user-defined for domain types as not all atlas versions support it.
 		typ = &UserDefinedType{T: c.fmtype}
 	}
 	return typ, nil
diff --git a/sql/postgres/diff.go b/sql/postgres/diff.go
index b8ea3efd..13693917 100644
--- a/sql/postgres/diff.go
+++ b/sql/postgres/diff.go
@@ -43,44 +43,6 @@ func skipDefaultComment(s *schema.Schema) []schema.Attr {
 	return attrs
 }
 
-// SchemaObjectDiff returns a changeset for migrating schema objects from
-// one state to the other.
-func (*diff) SchemaObjectDiff(from, to *schema.Schema) ([]schema.Change, error) {
-	var changes []schema.Change
-	// Drop or modify enums.
-	for _, o1 := range from.Objects {
-		e1, ok := o1.(*schema.EnumType)
-		if !ok {
-			return nil, fmt.Errorf("unsupported object type %T", o1)
-		}
-		o2, ok := to.Object(func(o schema.Object) bool {
-			e2, ok := o.(*schema.EnumType)
-			return ok && e1.T == e2.T
-		})
-		if !ok {
-			changes = append(changes, &schema.DropObject{O: o1})
-			continue
-		}
-		if e2 := o2.(*schema.EnumType); !sqlx.ValuesEqual(e1.Values, e2.Values) {
-			changes = append(changes, &schema.ModifyObject{From: e1, To: e2})
-		}
-	}
-	// Add new enums.
-	for _, o1 := range to.Objects {
-		e1, ok := o1.(*schema.EnumType)
-		if !ok {
-			return nil, fmt.Errorf("unsupported object type %T", o1)
-		}
-		if _, ok := from.Object(func(o schema.Object) bool {
-			e2, ok := o.(*schema.EnumType)
-			return ok && e1.T == e2.T
-		}); !ok {
-			changes = append(changes, &schema.AddObject{O: e1})
-		}
-	}
-	return changes, nil
-}
-
 // TableAttrDiff returns a changeset for migrating table attributes from one state to the other.
 func (d *diff) TableAttrDiff(from, to *schema.Table) ([]schema.Change, error) {
 	var changes []schema.Change
@@ -322,6 +284,10 @@ func (*diff) AnnotateChanges(changes []schema.Change, opts *schema.DiffOptions)
 }
 
 func (d *diff) typeChanged(from, to *schema.Column) (bool, error) {
+	return typeChanged(from, to, d.conn.schema)
+}
+
+func typeChanged(from, to *schema.Column, ns string) (bool, error) {
 	fromT, toT := from.Type.Type, to.Type.Type
 	if fromT == nil || toT == nil {
 		return false, fmt.Errorf("postgres: missing type information for column %q", from.Name)
@@ -348,7 +314,11 @@ func (d *diff) typeChanged(from, to *schema.Column) (bool, error) {
 		changed = toT.T != fromT.T &&
 			// In case the type is defined with schema qualifier, but returned without
 			// (inspecting a schema scope), or vice versa, remove before comparing.
-			d.schema != "" && d.trimSchema(toT.T) != d.trimSchema(toT.T)
+			ns != "" && trimSchema(toT.T, ns) != trimSchema(toT.T, ns)
+	case *DomainType:
+		toT := toT.(*DomainType)
+		changed = toT.T != fromT.T ||
+			(toT.Schema != nil && fromT.Schema != nil && toT.Schema.Name != fromT.Schema.Name)
 	case *schema.EnumType:
 		toT := toT.(*schema.EnumType)
 		// Column type was changed if the underlying enum type was changed.
@@ -383,11 +353,11 @@ func (d *diff) typeChanged(from, to *schema.Column) (bool, error) {
 }
 
 // trimSchema returns the given type without the schema qualifier.
-func (d *diff) trimSchema(t string) string {
+func trimSchema(t string, ns string) string {
 	if strings.HasPrefix(t, `"`) {
-		return strings.TrimPrefix(t, fmt.Sprintf("%q.", d.schema))
+		return strings.TrimPrefix(t, fmt.Sprintf("%q.", ns))
 	}
-	return strings.TrimPrefix(t, fmt.Sprintf("%s.", d.schema))
+	return strings.TrimPrefix(t, fmt.Sprintf("%s.", ns))
 }
 
 // defaultEqual reports if the DEFAULT values x and y
diff --git a/sql/postgres/driver_oss.go b/sql/postgres/driver_oss.go
index b8f6dfd7..b14917b6 100644
--- a/sql/postgres/driver_oss.go
+++ b/sql/postgres/driver_oss.go
@@ -8,10 +8,14 @@ package postgres
 
 import (
 	"context"
+	"fmt"
 
 	"ariga.io/atlas/schemahcl"
 	"ariga.io/atlas/sql/internal/specutil"
+	"ariga.io/atlas/sql/internal/sqlx"
+	"ariga.io/atlas/sql/migrate"
 	"ariga.io/atlas/sql/schema"
+	"ariga.io/atlas/sql/sqlspec"
 )
 
 var (
@@ -34,6 +38,10 @@ func (*inspect) inspectFuncs(context.Context, *schema.Realm, *schema.InspectOpti
 	return nil // unimplemented.
 }
 
+func (i *inspect) inspectTypes(context.Context, *schema.Realm, *schema.InspectOptions) error {
+	return nil // unimplemented.
+}
+
 func (*state) addView(*schema.AddView) error {
 	return nil // unimplemented.
 }
@@ -50,10 +58,6 @@ func (*state) renameView(*schema.RenameView) {
 	// unimplemented.
 }
 
-func (d *diff) ViewAttrChanged(_, _ *schema.View) bool {
-	return false // unimplemented.
-}
-
 func (s *state) addFunc(*schema.AddFunc) error {
 	return nil // unimplemented.
 }
@@ -86,6 +90,108 @@ func (s *state) renameProc(*schema.RenameProc) error {
 	return nil // unimplemented.
 }
 
+func (s *state) addObject(add *schema.AddObject) error {
+	switch o := add.O.(type) {
+	case *schema.EnumType:
+		create, drop := s.createDropEnum(o)
+		s.append(&migrate.Change{
+			Source:  add,
+			Cmd:     create,
+			Reverse: drop,
+			Comment: fmt.Sprintf("create enum type %q", o.T),
+		})
+	default:
+		// unsupported object type.
+	}
+	return nil
+}
+
+func (s *state) dropObject(drop *schema.DropObject) error {
+	switch o := drop.O.(type) {
+	case *schema.EnumType:
+		create, dropE := s.createDropEnum(o)
+		s.append(&migrate.Change{
+			Source:  drop,
+			Cmd:     dropE,
+			Reverse: create,
+			Comment: fmt.Sprintf("drop enum type %q", o.T),
+		})
+	default:
+		// unsupported object type.
+	}
+	return nil
+}
+
+func (s *state) modifyObject(modify *schema.ModifyObject) error {
+	if _, ok := modify.From.(*schema.EnumType); ok {
+		return s.alterEnum(modify)
+	}
+	return nil // unimplemented.
+}
+
+func (d *diff) ViewAttrChanged(_, _ *schema.View) bool {
+	return false // unimplemented.
+}
+
+// SchemaObjectDiff returns a changeset for migrating schema objects from
+// one state to the other.
+func (*diff) SchemaObjectDiff(from, to *schema.Schema) ([]schema.Change, error) {
+	var changes []schema.Change
+	// Drop or modify enums.
+	for _, o1 := range from.Objects {
+		e1, ok := o1.(*schema.EnumType)
+		if !ok {
+			continue // Unsupported object type.
+		}
+		o2, ok := to.Object(func(o schema.Object) bool {
+			e2, ok := o.(*schema.EnumType)
+			return ok && e1.T == e2.T
+		})
+		if !ok {
+			changes = append(changes, &schema.DropObject{O: o1})
+			continue
+		}
+		if e2 := o2.(*schema.EnumType); !sqlx.ValuesEqual(e1.Values, e2.Values) {
+			changes = append(changes, &schema.ModifyObject{From: e1, To: e2})
+		}
+	}
+	// Add new enums.
+	for _, o1 := range to.Objects {
+		e1, ok := o1.(*schema.EnumType)
+		if !ok {
+			continue // Unsupported object type.
+		}
+		if _, ok := from.Object(func(o schema.Object) bool {
+			e2, ok := o.(*schema.EnumType)
+			return ok && e1.T == e2.T
+		}); !ok {
+			changes = append(changes, &schema.AddObject{O: e1})
+		}
+	}
+	return changes, nil
+}
+
 func verifyChanges(context.Context, []schema.Change) error {
 	return nil // unimplemented.
 }
+
+func convertDomains(_ []*sqlspec.Table, domains []*Domain, _ *schema.Realm) error {
+	if len(domains) > 0 {
+		return fmt.Errorf("postgres: domains are not supported by this version. Use: https://atlasgo.io/getting-started")
+	}
+	return nil
+}
+
+// objectSpec converts from a concrete schema objects into specs.
+func objectSpec(d *doc, spec *specutil.SchemaSpec, s *schema.Schema) error {
+	for _, o := range s.Objects {
+		if e, ok := o.(*schema.EnumType); ok {
+			d.Enums = append(d.Enums, &Enum{
+				Name:   e.T,
+				Values: e.Values,
+				Schema: specutil.SchemaRef(spec.Schema.Name),
+			})
+		}
+	}
+	return nil
+}
diff --git a/sql/postgres/inspect.go b/sql/postgres/inspect.go
index a38580f5..f147ea35 100644
--- a/sql/postgres/inspect.go
+++ b/sql/postgres/inspect.go
@@ -5,6 +5,7 @@
 package postgres
 
 import (
+	"ariga.io/atlas/schemahcl"
 	"bytes"
 	"context"
 	"database/sql"
@@ -56,6 +57,11 @@ func (i *inspect) InspectRealm(ctx context.Context, opts *schema.InspectRealmOpt
 		if err := i.inspectEnums(ctx, r); err != nil {
 			return nil, err
 		}
+		if mode.Is(schema.InspectTypes) {
+			if err := i.inspectTypes(ctx, r, nil); err != nil {
+				return nil, err
+			}
+		}
 	}
 	return sqlx.ExcludeRealm(r, opts.Exclude)
 }
@@ -77,18 +83,19 @@ func (i *inspect) InspectSchema(ctx context.Context, name string, opts *schema.I
 		opts = &schema.InspectOptions{}
 	}
 	r := schema.NewRealm(schemas...)
-	if sqlx.ModeInspectSchema(opts).Is(schema.InspectTables) {
+	mode := sqlx.ModeInspectSchema(opts)
+	if mode.Is(schema.InspectTables) {
 		if err := i.inspectTables(ctx, r, opts); err != nil {
 			return nil, err
 		}
 		sqlx.LinkSchemaTables(schemas)
 	}
-	if sqlx.ModeInspectSchema(opts).Is(schema.InspectViews) {
+	if mode.Is(schema.InspectViews) {
 		if err := i.inspectViews(ctx, r, opts); err != nil {
 			return nil, err
 		}
 	}
-	if sqlx.ModeInspectSchema(opts).Is(schema.InspectFuncs) {
+	if mode.Is(schema.InspectFuncs) {
 		if err := i.inspectFuncs(ctx, r, opts); err != nil {
 			return nil, err
 		}
@@ -96,6 +103,11 @@ func (i *inspect) InspectSchema(ctx context.Context, name string, opts *schema.I
 	if err := i.inspectEnums(ctx, r); err != nil {
 		return nil, err
 	}
+	if mode.Is(schema.InspectTypes) {
+		if err := i.inspectTypes(ctx, r, opts); err != nil {
+			return nil, err
+		}
+	}
 	return sqlx.ExcludeSchema(r.Schemas[0], opts.Exclude)
 }
 
@@ -212,8 +224,14 @@ func (i *inspect) addColumn(s *schema.Schema, rows *sql.Rows) (err error) {
 	c := &schema.Column{
 		Name: name.String,
 		Type: &schema.ColumnType{
-			Raw:  typ.String,
 			Null: nullable.String == "YES",
+			Raw: func() string {
+				// For domains, use the domain type instead of the base type.
+				if typtype.String == "d" {
+					return fmtype.String
+				}
+				return typ.String
+			}(),
 		},
 	}
 	c.Type.Type, err = columnType(&columnDesc{
@@ -783,6 +801,18 @@ type (
 		Len int64
 	}
 
+	// DomainType represents a domain type.
+	// https://www.postgresql.org/docs/current/domains.html
+	DomainType struct {
+		schema.Type
+		schema.Object
+		T       string          // Type name.
+		Schema  *schema.Schema  // Optional schema.
+		Null    bool            // Nullability.
+		Default schema.Expr     // Default value.
+		Checks  []*schema.Check // Check constraints.
+	}
+
 	// IntervalType defines an interval type.
 	// https://postgresql.org/docs/current/datatype-datetime.html
 	IntervalType struct {
@@ -983,6 +1013,11 @@ type (
 	ReferenceOption schema.ReferenceOption
 )
 
+// Ref returns a reference to the domain type.
+func (d *DomainType) Ref() *schemahcl.Ref {
+	return &schemahcl.Ref{V: "$domain." + d.T}
+}
+
 // String implements fmt.Stringer interface.
 func (o ReferenceOption) String() string {
 	return string(o)
@@ -1179,28 +1214,33 @@ func newIndexStorage(opts string) (*IndexStorageParams, error) {
 	return params, nil
 }
 
-// reEnumType extracts the enum type and an option schema qualifier.
-var reEnumType = regexp.MustCompile(`^(?:(".+"|\w+)\.)?(".+"|\w+)$`)
-
-func newEnumType(t string, id int64) *enumType {
-	var (
-		e     = &enumType{T: t, ID: id}
-		parts = reEnumType.FindStringSubmatch(e.T)
-		r     = func(s string) string {
-			s = strings.ReplaceAll(s, `""`, `"`)
-			if len(s) > 1 && s[0] == '"' && s[len(s)-1] == '"' {
-				s = s[1 : len(s)-1]
-			}
-			return s
+// reFmtType extracts the formatted type and an option schema qualifier.
+var reFmtType = regexp.MustCompile(`^(?:(".+"|\w+)\.)?(".+"|\w+)$`)
+
+// parseFmtType parses the formatted type returned from pg_catalog.format_type
+// and extract the schema and type name.
+func parseFmtType(t string) (s, n string) {
+	n = t
+	parts := reFmtType.FindStringSubmatch(t)
+	r := func(s string) string {
+		s = strings.ReplaceAll(s, `""`, `"`)
+		if len(s) > 1 && s[0] == '"' && s[len(s)-1] == '"' {
+			s = s[1 : len(s)-1]
 		}
-	)
+		return s
+	}
 	if len(parts) > 1 {
-		e.Schema = r(parts[1])
+		s = r(parts[1])
 	}
 	if len(parts) > 2 {
-		e.T = r(parts[2])
+		n = r(parts[2])
 	}
-	return e
+	return s, n
+}
+
+func newEnumType(t string, id int64) *enumType {
+	s, n := parseFmtType(t)
+	return &enumType{T: n, Schema: s, ID: id}
 }
 
 const (
diff --git a/sql/postgres/migrate.go b/sql/postgres/migrate.go
index 0beb1656..2304ef56 100644
--- a/sql/postgres/migrate.go
+++ b/sql/postgres/migrate.go
@@ -144,17 +144,7 @@ func (s *state) plan(changes []schema.Change) error {
 		case *schema.DropTable:
 			err = s.dropTable(c)
 		case *schema.DropObject:
-			e, ok := c.O.(*schema.EnumType)
-			if !ok {
-				return fmt.Errorf("unsupported drop object %T", c.O)
-			}
-			create, rv := s.createDropEnum(e)
-			s.append(&migrate.Change{
-				Source:  c,
-				Cmd:     rv,
-				Reverse: create,
-				Comment: fmt.Sprintf("drop enum type %q", e.T),
-			})
+			err = s.dropObject(c)
 		case *schema.DropFunc:
 			err = s.dropFunc(c)
 		case *schema.DropProc:
@@ -225,19 +215,11 @@ func (s *state) topLevel(changes []schema.Change) ([]schema.Change, error) {
 				Comment: fmt.Sprintf("Drop schema named %q", c.S.Name),
 			})
 		case *schema.AddObject:
-			e, ok := c.O.(*schema.EnumType)
-			if !ok {
-				return nil, fmt.Errorf("unsupported object %T", c.O)
+			if err := s.addObject(c); err != nil {
+				return nil, err
 			}
-			create, drop := s.createDropEnum(e)
-			s.append(&migrate.Change{
-				Source:  c,
-				Cmd:     create,
-				Reverse: drop,
-				Comment: fmt.Sprintf("create enum type %q", e.T),
-			})
 		case *schema.ModifyObject:
-			if err := s.alterEnum(c); err != nil {
+			if err := s.modifyObject(c); err != nil {
 				return nil, err
 			}
 		case *schema.RenameObject:
@@ -1239,16 +1221,24 @@ func (s *state) createDropEnum(e *schema.EnumType) (string, string) {
 }
 
 func (s *state) enumIdent(e *schema.EnumType) string {
+	return s.typeIdent(e.Schema, e.T)
+}
+
+func (s *state) domainIdent(d *DomainType) string {
+	return s.typeIdent(d.Schema, d.T)
+}
+
+func (s *state) typeIdent(ns *schema.Schema, name string) string {
 	switch {
 	// In case the plan uses a specific schema qualifier.
 	case s.SchemaQualifier != nil:
 		if *s.SchemaQualifier != "" {
-			return fmt.Sprintf("%q.%q", *s.SchemaQualifier, e.T)
+			return fmt.Sprintf("%q.%q", *s.SchemaQualifier, name)
 		}
-	case e.Schema != nil && e.Schema.Name != "":
-		return fmt.Sprintf("%q.%q", e.Schema.Name, e.T)
+	case ns != nil && ns.Name != "":
+		return fmt.Sprintf("%q.%q", ns.Name, name)
 	}
-	return strconv.Quote(e.T)
+	return strconv.Quote(name)
 }
 
 // schemaPrefix returns the schema prefix based on the planner config.
@@ -1270,9 +1260,14 @@ func (s *state) formatType(t schema.Type) (string, error) {
 	switch t := t.(type) {
 	case *schema.EnumType:
 		return s.enumIdent(t), nil
+	case *DomainType:
+		return s.domainIdent(t), nil
 	case *ArrayType:
-		if e, ok := t.Type.(*schema.EnumType); ok {
-			return s.enumIdent(e) + "[]", nil
+		switch t := t.Type.(type) {
+		case *schema.EnumType:
+			return s.enumIdent(t) + "[]", nil
+		case *DomainType:
+			return s.domainIdent(t) + "[]", nil
 		}
 	}
 	return FormatType(t)
diff --git a/sql/postgres/sqlspec.go b/sql/postgres/sqlspec.go
index 76810cbf..fd9a59cd 100644
--- a/sql/postgres/sqlspec.go
+++ b/sql/postgres/sqlspec.go
@@ -27,11 +27,13 @@ type (
 		Views        []*sqlspec.View   `spec:"view"`
 		Materialized []*sqlspec.View   `spec:"materialized"`
 		Enums        []*Enum           `spec:"enum"`
+		Domains      []*Domain         `spec:"domain"`
 		Funcs        []*sqlspec.Func   `spec:"function"`
 		Procs        []*sqlspec.Func   `spec:"procedure"`
 		Schemas      []*sqlspec.Schema `spec:"schema"`
 	}
-	// Enum holds a specification for an enum, that can be referenced as a column type.
+
+	// Enum holds a specification for an enum type.
 	Enum struct {
 		Name      string         `spec:",name"`
 		Qualifier string         `spec:",qualifier"`
@@ -39,6 +41,18 @@ type (
 		Values    []string       `spec:"values"`
 		schemahcl.DefaultExtension
 	}
+
+	// Domain holds a specification for a domain type.
+	Domain struct {
+		Name      string           `spec:",name"`
+		Qualifier string           `spec:",qualifier"`
+		Schema    *schemahcl.Ref   `spec:"schema"`
+		Type      *schemahcl.Type  `spec:"type"`
+		Null      bool             `spec:"null"`
+		Default   cty.Value        `spec:"default"`
+		Checks    []*sqlspec.Check `spec:"check"`
+		schemahcl.DefaultExtension
+	}
 )
 
 // merge merges the doc d1 into d.
@@ -46,6 +60,7 @@ func (d *doc) merge(d1 *doc) {
 	d.Tables = append(d.Tables, d1.Tables...)
 	d.Views = append(d.Views, d1.Views...)
 	d.Materialized = append(d.Materialized, d1.Materialized...)
+	d.Domains = append(d.Domains, d1.Domains...)
 	d.Enums = append(d.Enums, d1.Enums...)
 	d.Funcs = append(d.Funcs, d1.Funcs...)
 	d.Procs = append(d.Procs, d1.Procs...)
@@ -64,8 +79,21 @@ func (e *Enum) SetQualifier(q string) { e.Qualifier = q }
 // SchemaRef returns the schema reference for the enum.
 func (e *Enum) SchemaRef() *schemahcl.Ref { return e.Schema }
 
+// Label returns the defaults label used for the enum resource.
+func (d *Domain) Label() string { return d.Name }
+
+// QualifierLabel returns the qualifier label used for the enum resource, if any.
+func (d *Domain) QualifierLabel() string { return d.Qualifier }
+
+// SetQualifier sets the qualifier label used for the enum resource.
+func (d *Domain) SetQualifier(q string) { d.Qualifier = q }
+
+// SchemaRef returns the schema reference for the enum.
+func (d *Domain) SchemaRef() *schemahcl.Ref { return d.Schema }
+
 func init() {
 	schemahcl.Register("enum", &Enum{})
+	schemahcl.Register("domain", &Domain{})
 }
 
 // evalSpec evaluates an Atlas DDL document into v using the input.
@@ -82,10 +110,11 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 		); err != nil {
 			return fmt.Errorf("specutil: failed converting to *schema.Realm: %w", err)
 		}
-		if len(d.Enums) > 0 {
-			if err := convertEnums(d.Tables, d.Enums, v); err != nil {
-				return err
-			}
+		if err := convertEnums(d.Tables, d.Enums, v); err != nil {
+			return err
+		}
+		if err := convertDomains(d.Tables, d.Domains, v); err != nil {
+			return err
 		}
 	case *schema.Schema:
 		var d doc
@@ -105,11 +134,14 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 		if err := convertEnums(d.Tables, d.Enums, r); err != nil {
 			return err
 		}
+		if err := convertDomains(d.Tables, d.Domains, r); err != nil {
+			return err
+		}
 		*v = *r.Schemas[0]
 	case schema.Schema, schema.Realm:
 		return fmt.Errorf("postgres: Eval expects a pointer: received %[1]T, expected *%[1]T", v)
 	default:
-		return hclState.Eval(p, v, input)
+		return fmt.Errorf("postgres: unexpected type %T", v)
 	}
 	return nil
 }
@@ -144,6 +176,9 @@ func MarshalSpec(v any, marshaler schemahcl.Marshaler) ([]byte, error) {
 		if err := specutil.QualifyObjects(d.Enums); err != nil {
 			return nil, err
 		}
+		if err := specutil.QualifyObjects(d.Domains); err != nil {
+			return nil, err
+		}
 		if err := specutil.QualifyObjects(d.Funcs); err != nil {
 			return nil, err
 		}
@@ -500,6 +535,9 @@ func convertColumnType(spec *sqlspec.Column) (schema.Type, error) {
 // convertEnums converts possibly referenced column types (like enums) to
 // an actual schema.Type and sets it on the correct schema.Column.
 func convertEnums(tables []*sqlspec.Table, enums []*Enum, r *schema.Realm) error {
+	if len(enums) == 0 {
+		return nil
+	}
 	byName := make(map[string]*schema.EnumType)
 	for _, e := range enums {
 		if byName[e.Name] != nil {
@@ -514,14 +552,14 @@ func convertEnums(tables []*sqlspec.Table, enums []*Enum, r *schema.Realm) error
 			return fmt.Errorf("schema %q defined on enum %q was not found in realm", ns, e.Name)
 		}
 		e1 := &schema.EnumType{T: e.Name, Schema: es, Values: e.Values}
-		es.Objects = append(es.Objects, e1)
+		es.AddObjects(e1)
 		byName[e.Name] = e1
 	}
 	for _, t := range tables {
 		for _, c := range t.Columns {
 			var enum *schema.EnumType
 			switch {
-			case c.Type.IsRef:
+			case c.Type.IsRefTo("enum"):
 				n, err := enumName(c.Type)
 				if err != nil {
 					return err
@@ -532,11 +570,12 @@ func convertEnums(tables []*sqlspec.Table, enums []*Enum, r *schema.Realm) error
 				}
 				enum = e
 			default:
-				n, ok := arrayType(c.Type.T)
-				if !ok || byName[n] == nil {
-					continue
+				if n, ok := arrayType(c.Type.T); ok {
+					enum = byName[n]
 				}
-				enum = byName[n]
+			}
+			if enum == nil {
+				continue
 			}
 			schemaT, err := specutil.SchemaName(t.Schema)
 			if err != nil {
@@ -595,15 +634,10 @@ func schemaSpec(s *schema.Schema) (*doc, error) {
 		Procs:        spec.Procs,
 		Schemas:      []*sqlspec.Schema{spec.Schema},
 		Enums:        make([]*Enum, 0, len(s.Objects)),
+		Domains:      make([]*Domain, 0, len(s.Objects)),
 	}
-	for _, o := range s.Objects {
-		if e, ok := o.(*schema.EnumType); ok {
-			d.Enums = append(d.Enums, &Enum{
-				Name:   e.T,
-				Values: e.Values,
-				Schema: specutil.SchemaRef(spec.Schema.Name),
-			})
-		}
+	if err := objectSpec(d, spec, s); err != nil {
+		return nil, err
 	}
 	return d, nil
 }
@@ -738,17 +772,24 @@ func fromIdentity(i *Identity) *schemahcl.Resource {
 // columnTypeSpec converts from a concrete Postgres schema.Type into sqlspec.Column Type.
 func columnTypeSpec(t schema.Type) (*sqlspec.Column, error) {
 	// Handle postgres enum types. They cannot be put into the TypeRegistry since their name is dynamic.
-	if e, ok := t.(*schema.EnumType); ok {
+	switch o := t.(type) {
+	case *schema.EnumType:
 		return &sqlspec.Column{Type: &schemahcl.Type{
-			T:     enumRef(e.T).V,
+			T:     enumRef(o.T).V,
 			IsRef: true,
 		}}, nil
+	case *DomainType:
+		return &sqlspec.Column{Type: &schemahcl.Type{
+			T:     o.Ref().V,
+			IsRef: true,
+		}}, nil
+	default:
+		st, err := TypeRegistry.Convert(t)
+		if err != nil {
+			return nil, err
+		}
+		return &sqlspec.Column{Type: st}, nil
 	}
-	st, err := TypeRegistry.Convert(t)
-	if err != nil {
-		return nil, err
-	}
-	return &sqlspec.Column{Type: st}, nil
 }
 
 // TypeRegistry contains the supported TypeSpecs for the Postgres driver.
diff --git a/sql/schema/inspect.go b/sql/schema/inspect.go
index fe2cce17..018bc031 100644
--- a/sql/schema/inspect.go
+++ b/sql/schema/inspect.go
@@ -49,6 +49,9 @@ const (
 
 	// InspectFuncs enables schema functions / procedures inspection.
 	InspectFuncs
+
+	// InspectTypes enables schema types inspection.
+	InspectTypes
 )
 
 // Is reports whether the given mode is enabled.


Test patch:
diff --git a/sql/postgres/inspect_test.go b/sql/postgres/inspect_test.go
index e9ace09c..00d039ff 100644
--- a/sql/postgres/inspect_test.go
+++ b/sql/postgres/inspect_test.go
@@ -145,7 +145,7 @@ func TestDriver_InspectTable(t *testing.T) {
 					{Name: "c32", Type: &schema.ColumnType{Raw: "bigserial", Type: &SerialType{T: "bigserial", SequenceName: "t1_c32_seq"}}},
 					{Name: "c33", Type: &schema.ColumnType{Raw: `status".`, Type: statusE}, Default: &schema.Literal{V: "'unknown'"}},
 					{Name: "c34", Type: &schema.ColumnType{Raw: "ARRAY", Type: &ArrayType{T: "state[]", Type: stateE}}},
-					{Name: "c35", Type: &schema.ColumnType{Raw: "character", Type: &UserDefinedType{T: "domain_char"}}},
+					{Name: "c35", Type: &schema.ColumnType{Raw: "domain_char", Type: &UserDefinedType{T: "domain_char"}}},
 					{Name: "c36", Type: &schema.ColumnType{Raw: "tsvector", Type: &TextSearchType{T: "tsvector"}}},
 					{Name: "c37", Type: &schema.ColumnType{Raw: "tsquery", Type: &TextSearchType{T: "tsquery"}}},
 					{Name: "c38", Type: &schema.ColumnType{Raw: "datemultirange", Type: &RangeType{T: "datemultirange"}}},