Repository: ariga/atlas
Pull request #1353 (2022-12-15 11:41:35)

schemahcl: scan and format quoted identifiers


Patch:
diff --git a/schemahcl/context.go b/schemahcl/context.go
index 2b3243f3..06378a8e 100644
--- a/schemahcl/context.go
+++ b/schemahcl/context.go
@@ -284,18 +284,25 @@ func addr(parentAddr, typeName, blkName, qualifier string) string {
 	for _, s := range []string{qualifier, blkName} {
 		switch {
 		case s == "":
-		case strings.Contains(s, "."):
+		case validIdent(s):
+			b.WriteString(".")
+			b.WriteString(s)
+		default:
 			b.WriteString("[")
 			b.WriteString(strconv.Quote(s))
 			b.WriteString("]")
-		default:
-			b.WriteString(".")
-			b.WriteString(s)
 		}
 	}
 	return b.String()
 }
 
+// validIdent reports if the given string can
+// be used as an identifier in a reference.
+func validIdent(s string) bool {
+	_, err := cty.ParseNumberVal(s)
+	return err == nil || hclsyntax.ValidIdentifier(s)
+}
+
 func blockName(blk *hclsyntax.Block) (qualifier string, name string) {
 	switch len(blk.Labels) {
 	case 0:
diff --git a/schemahcl/hcl.go b/schemahcl/hcl.go
index ba49daed..40d395da 100644
--- a/schemahcl/hcl.go
+++ b/schemahcl/hcl.go
@@ -399,14 +399,22 @@ func (s *State) writeAttr(attr *Attr, body *hclwrite.Body) error {
 		if err != nil {
 			return err
 		}
-		body.SetAttributeRaw(attr.K, hclRefTokens(v))
+		ts, err := hclRefTokens(v)
+		if err != nil {
+			return err
+		}
+		body.SetAttributeRaw(attr.K, ts)
 	case attr.IsType():
 		t, err := attr.Type()
 		if err != nil {
 			return err
 		}
 		if t.IsRef {
-			body.SetAttributeRaw(attr.K, hclRefTokens(t.T))
+			ts, err := hclRefTokens(t.T)
+			if err != nil {
+				return err
+			}
+			body.SetAttributeRaw(attr.K, ts)
 			break
 		}
 		spec, ok := s.findTypeSpec(t.T)
@@ -441,7 +449,11 @@ func (s *State) writeAttr(attr *Attr, body *hclwrite.Body) error {
 				if !ok {
 					return fmt.Errorf("unsupported capsule type: %v", v.Type())
 				}
-				tokens = append(tokens, hclRefTokens(ref.V))
+				ts, err := hclRefTokens(ref.V)
+				if err != nil {
+					return err
+				}
+				tokens = append(tokens, ts)
 			} else {
 				tokens = append(tokens, hclwrite.TokensForValue(v))
 			}
@@ -515,37 +527,35 @@ func findAttr(attrs []*Attr, k string) (*Attr, bool) {
 	return nil, false
 }
 
-func hclRefTokens(ref string) hclwrite.Tokens {
-	var t []*hclwrite.Token
-	for i, s := range strings.Split(ref, ".") {
-		// Ignore the first $ as token for reference.
-		if len(s) > 1 && s[0] == '$' {
-			s = s[1:]
-		}
-		switch {
-		case i == 0:
-			t = append(t, hclRawTokens(s)...)
-		case hclsyntax.ValidIdentifier(s):
-			t = append(t, &hclwrite.Token{
-				Type:  hclsyntax.TokenDot,
-				Bytes: []byte{'.'},
-			}, &hclwrite.Token{
-				Type:  hclsyntax.TokenIdent,
-				Bytes: []byte(s),
-			})
-		default:
-			t = append(t, &hclwrite.Token{
-				Type:  hclsyntax.TokenOBrack,
-				Bytes: []byte{'['},
-			})
-			t = append(t, hclwrite.TokensForValue(cty.StringVal(s))...)
-			t = append(t, &hclwrite.Token{
-				Type:  hclsyntax.TokenCBrack,
-				Bytes: []byte{']'},
-			})
+func hclRefTokens(v string) (t hclwrite.Tokens, err error) {
+	// If it is a reference to a type or an enum.
+	if !strings.HasPrefix(v, "$") {
+		return []*hclwrite.Token{{Type: hclsyntax.TokenIdent, Bytes: []byte(v)}}, nil
+	}
+	path, err := (&Ref{V: v}).Path()
+	if err != nil {
+		return nil, err
+	}
+	for i, p := range path {
+		if i > 0 {
+			t = append(t, &hclwrite.Token{Type: hclsyntax.TokenDot, Bytes: []byte{'.'}})
+		}
+		t = append(t, &hclwrite.Token{Type: hclsyntax.TokenIdent, Bytes: []byte(p.T)})
+		for _, v := range p.V {
+			switch {
+			case validIdent(v):
+				t = append(t,
+					&hclwrite.Token{Type: hclsyntax.TokenDot, Bytes: []byte{'.'}},
+					&hclwrite.Token{Type: hclsyntax.TokenIdent, Bytes: []byte(v)},
+				)
+			default:
+				t = append(t, &hclwrite.Token{Type: hclsyntax.TokenOBrack, Bytes: []byte{'['}})
+				t = append(t, hclwrite.TokensForValue(cty.StringVal(v))...)
+				t = append(t, &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte{']'}})
+			}
 		}
 	}
-	return t
+	return t, nil
 }
 
 func hclRawTokens(s string) hclwrite.Tokens {
diff --git a/schemahcl/spec.go b/schemahcl/spec.go
index 27afd651..472cfe4e 100644
--- a/schemahcl/spec.go
+++ b/schemahcl/spec.go
@@ -8,9 +8,9 @@ import (
 	"fmt"
 	"math/big"
 	"reflect"
+	"strings"
 
 	"ariga.io/atlas/sql/schema"
-
 	"github.com/zclconf/go-cty/cty"
 	"github.com/zclconf/go-cty/cty/gocty"
 )
@@ -214,6 +214,133 @@ func (a *Attr) Strings() (vs []string, err error) {
 	return vs, nil
 }
 
+// PathIndex represents an index in a reference path.
+type PathIndex struct {
+	T string   // type
+	V []string // identifiers
+}
+
+// Check if the path index is valid.
+func (p *PathIndex) Check() error {
+	if p.T == "" || len(p.V) == 0 {
+		return fmt.Errorf("schemahcl: missing type or identifier %v", p)
+	}
+	for _, v := range p.V {
+		if v == "" {
+			return fmt.Errorf("schemahcl: empty identifier %v", p)
+		}
+	}
+	return nil
+}
+
+// ByType returns the path index for the given type.
+func (r *Ref) ByType(name string) ([]string, error) {
+	if r == nil {
+		return nil, fmt.Errorf("schemahcl: type %q was not found in nil reference", name)
+	}
+	path, err := r.Path()
+	if err != nil {
+		return nil, err
+	}
+	var vs []string
+	for _, p := range path {
+		switch {
+		case p.T != name:
+		case vs != nil:
+			return nil, fmt.Errorf("schemahcl: multiple %q found in reference", name)
+		default:
+			if err := p.Check(); err != nil {
+				return nil, err
+			}
+			vs = p.V
+		}
+	}
+	if vs == nil {
+		return nil, fmt.Errorf("schemahcl: missing %q in reference", name)
+	}
+	return vs, nil
+}
+
+// Path returns a parsed path including block types and their identifiers.
+func (r *Ref) Path() (path []PathIndex, err error) {
+	for i := 0; i < len(r.V); i++ {
+		var part PathIndex
+		switch idx := strings.IndexAny(r.V[i:], ".["); {
+		case r.V[i] != '$':
+			return nil, fmt.Errorf("schemahcl: missing type in reference %q", r.V[i:])
+		case idx == -1:
+			return nil, fmt.Errorf("schemahcl: missing identifier in reference %q", r.V[i:])
+		default:
+			part.T = r.V[i+1 : i+idx]
+			i += idx
+		}
+	Ident:
+		for i < len(r.V) {
+			switch {
+			// End of identifier before a type.
+			case strings.HasPrefix(r.V[i:], ".$"):
+				break Ident
+			// Scan identifier.
+			case r.V[i] == '.':
+				v := r.V[i+1:]
+				if idx := strings.IndexAny(v, ".["); idx != -1 {
+					v = v[:idx]
+				}
+				part.V = append(part.V, v)
+				i += 1 + len(v)
+			// Scan attribute (["..."]).
+			case strings.HasPrefix(r.V[i:], "[\""):
+				idx := scanString(r.V[i+2:])
+				if idx == -1 {
+					return nil, fmt.Errorf("schemahcl: unterminated string in reference %q", r.V[i:])
+				}
+				v := r.V[i+2 : i+2+idx]
+				i += 2 + idx
+				if !strings.HasPrefix(r.V[i:], "\"]") {
+					return nil, fmt.Errorf("schemahcl: missing ']' in reference %q", r.V[i:])
+				}
+				part.V = append(part.V, v)
+				i += 2
+			default:
+				return nil, fmt.Errorf("schemahcl: invalid character in reference %q", r.V[i:])
+			}
+		}
+		if err := part.Check(); err != nil {
+			return nil, err
+		}
+		path = append(path, part)
+	}
+	return
+}
+
+// BuildRef from a path.
+func BuildRef(path []PathIndex) *Ref {
+	var v string
+	for _, p := range path {
+		switch {
+		case len(p.V) == 1:
+			v = addr(v, p.T, p.V[0], "")
+		case len(p.V) == 2:
+			v = addr(v, p.T, p.V[1], p.V[0])
+		default:
+			v = addr(v, p.T, "", "")
+		}
+	}
+	return &Ref{V: v}
+}
+
+func scanString(s string) int {
+	for i := 0; i < len(s); i++ {
+		switch s[i] {
+		case '\\':
+			i++
+		case '"':
+			return i
+		}
+	}
+	return -1
+}
+
 // Bools returns a slice of bools from the Value of the Attr. If The value is not a ListValue or its
 // values cannot be converted to bools an error is returned.
 func (a *Attr) Bools() (vs []bool, err error) {
diff --git a/sql/internal/specutil/convert.go b/sql/internal/specutil/convert.go
index ac81a894..93c19c08 100644
--- a/sql/internal/specutil/convert.go
+++ b/sql/internal/specutil/convert.go
@@ -5,7 +5,6 @@
 package specutil
 
 import (
-	"errors"
 	"fmt"
 	"strconv"
 	"strings"
@@ -37,32 +36,35 @@ type (
 
 // Scan populates the Realm from the schemas and table specs.
 func Scan(r *schema.Realm, schemas []*sqlspec.Schema, tables []*sqlspec.Table, convertTable ConvertTableFunc) error {
-	// Build the schemas.
-	for _, schemaSpec := range schemas {
-		sch := &schema.Schema{Name: schemaSpec.Name, Realm: r}
-		for _, tableSpec := range tables {
-			name, err := SchemaName(tableSpec.Schema)
-			if err != nil {
-				return fmt.Errorf("specutil: cannot extract schema name for table %q: %w", tableSpec.Name, err)
-			}
-			if name == schemaSpec.Name {
-				tbl, err := convertTable(tableSpec, sch)
-				if err != nil {
-					return err
-				}
-				sch.Tables = append(sch.Tables, tbl)
-			}
+	byName := make(map[string]*schema.Schema)
+	for _, spec := range schemas {
+		s := &schema.Schema{Name: spec.Name, Realm: r}
+		r.AddSchemas(s)
+		byName[spec.Name] = s
+	}
+	for _, spec := range tables {
+		name, err := SchemaName(spec.Schema)
+		if err != nil {
+			return fmt.Errorf("specutil: cannot extract schema name for table %q: %w", spec.Name, err)
 		}
-		r.Schemas = append(r.Schemas, sch)
+		s, ok := byName[name]
+		if !ok {
+			return fmt.Errorf("specutil: schema %q not found for table %q", name, spec.Name)
+		}
+		t, err := convertTable(spec, s)
+		if err != nil {
+			return fmt.Errorf("specutil: cannot convert table %q: %w", spec.Name, err)
+		}
+		s.AddTables(t)
 	}
 	// Link the foreign keys.
-	for _, sch := range r.Schemas {
-		for _, tbl := range sch.Tables {
-			tableSpec, err := findTableSpec(tables, sch.Name, tbl.Name)
+	for _, s := range r.Schemas {
+		for _, t := range s.Tables {
+			spec, err := findTableSpec(tables, s.Name, t.Name)
 			if err != nil {
 				return err
 			}
-			if err := linkForeignKeys(tbl, sch, tableSpec); err != nil {
+			if err := linkForeignKeys(t, s, spec); err != nil {
 				return err
 			}
 		}
@@ -554,25 +556,28 @@ func FromCheck(s *schema.Check) *sqlspec.Check {
 
 // SchemaName returns the name from a ref to a schema.
 func SchemaName(ref *schemahcl.Ref) (string, error) {
-	if ref == nil {
-		return "", errors.New("missing 'schema' attribute")
+	vs, err := ref.ByType("schema")
+	if err != nil {
+		return "", err
 	}
-	parts := strings.Split(ref.V, ".")
-	if len(parts) < 2 || parts[0] != "$schema" {
-		return "", errors.New("expected ref format of $schema.name")
+	if len(vs) != 1 {
+		return "", fmt.Errorf("specutil: expected 1 schema ref, got %d", len(vs))
 	}
-	return parts[1], nil
+	return vs[0], nil
 }
 
 // ColumnByRef returns a column from the table by its reference.
 func ColumnByRef(t *schema.Table, ref *schemahcl.Ref) (*schema.Column, error) {
-	s := strings.Split(ref.V, "$column.")
-	if len(s) != 2 {
-		return nil, fmt.Errorf("specutil: failed to extract column name from %q", ref)
+	vs, err := ref.ByType("column")
+	if err != nil {
+		return nil, err
 	}
-	c, ok := t.Column(s[1])
+	if len(vs) != 1 {
+		return nil, fmt.Errorf("specutil: expected 1 column ref, got %d", len(vs))
+	}
+	c, ok := t.Column(vs[0])
 	if !ok {
-		return nil, fmt.Errorf("specutil: unknown column %q in table %q", s[1], t.Name)
+		return nil, fmt.Errorf("specutil: unknown column %q in table %q", vs[0], t.Name)
 	}
 	return c, nil
 }
@@ -629,19 +634,17 @@ func findTable(ref *schemahcl.Ref, sch *schema.Schema) (*schema.Table, error) {
 }
 
 func tableName(ref *schemahcl.Ref) (qualifier, name string, err error) {
-	s := strings.Split(ref.V, "$column.")
-	if len(s) != 2 {
-		return "", "", fmt.Errorf("sqlspec: failed to split by column name from %q", ref)
+	vs, err := ref.ByType("table")
+	if err != nil {
+		return "", "", err
 	}
-	table := strings.TrimSuffix(s[0], ".")
-	s = strings.Split(table, ".")
-	switch len(s) {
+	switch len(vs) {
+	case 1:
+		name = vs[0]
 	case 2:
-		name = s[1]
-	case 3:
-		qualifier, name = s[1], s[2]
+		qualifier, name = vs[0], vs[1]
 	default:
-		return "", "", fmt.Errorf("sqlspec: failed to extract table name from %q", s)
+		return "", "", fmt.Errorf("sqlspec: unexpected number of references in %q", vs)
 	}
 	return
 }
@@ -652,20 +655,30 @@ func isLocalRef(r *schemahcl.Ref) bool {
 
 // ColumnRef returns the reference of a column by its name.
 func ColumnRef(cName string) *schemahcl.Ref {
-	return &schemahcl.Ref{V: "$column." + cName}
+	return schemahcl.BuildRef([]schemahcl.PathIndex{
+		{T: "column", V: []string{cName}},
+	})
 }
 
 func externalColRef(cName string, tName string) *schemahcl.Ref {
-	return &schemahcl.Ref{V: "$table." + tName + ".$column." + cName}
+	return schemahcl.BuildRef([]schemahcl.PathIndex{
+		{T: "table", V: []string{tName}},
+		{T: "column", V: []string{cName}},
+	})
 }
 
 func qualifiedExternalColRef(cName, tName, sName string) *schemahcl.Ref {
-	return &schemahcl.Ref{V: "$table." + sName + "." + tName + ".$column." + cName}
+	return schemahcl.BuildRef([]schemahcl.PathIndex{
+		{T: "table", V: []string{sName, tName}},
+		{T: "column", V: []string{cName}},
+	})
 }
 
 // SchemaRef returns the schemahcl.Ref to the schema with the given name.
-func SchemaRef(n string) *schemahcl.Ref {
-	return &schemahcl.Ref{V: "$schema." + n}
+func SchemaRef(name string) *schemahcl.Ref {
+	return schemahcl.BuildRef([]schemahcl.PathIndex{
+		{T: "schema", V: []string{name}},
+	})
 }
 
 // Attrer is the interface that wraps the Attr method.
diff --git a/sql/mysql/sqlspec.go b/sql/mysql/sqlspec.go
index 7435c739..1f327b19 100644
--- a/sql/mysql/sqlspec.go
+++ b/sql/mysql/sqlspec.go
@@ -37,12 +37,12 @@ func evalSpec(p *hclparse.Parser, v any, input map[string]cty.Value) error {
 		if err != nil {
 			return fmt.Errorf("mysql: failed converting to *schema.Realm: %w", err)
 		}
-		for _, schemaSpec := range d.Schemas {
-			schm, ok := v.Schema(schemaSpec.Name)
+		for _, spec := range d.Schemas {
+			s, ok := v.Schema(spec.Name)
 			if !ok {
-				return fmt.Errorf("could not find schema: %q", schemaSpec.Name)
+				return fmt.Errorf("could not find schema: %q", spec.Name)
 			}
-			if err := convertCharset(schemaSpec, &schm.Attrs); err != nil {
+			if err := convertCharset(spec, &s.Attrs); err != nil {
 				return err
 			}
 		}


Test patch:
diff --git a/schemahcl/context_test.go b/schemahcl/context_test.go
index fa3c0a64..1123dd97 100644
--- a/schemahcl/context_test.go
+++ b/schemahcl/context_test.go
@@ -317,18 +317,18 @@ func TestSchemaRefParse(t *testing.T) {
 		Points []*Point `spec:"point"`
 	}{
 		Points: []*Point{
-			{Z: []*Ref{{V: "$a"}}},
-			{Z: []*Ref{{V: "b"}}},
+			{Z: []*Ref{{V: "$point.1"}}},
+			{Z: []*Ref{{V: "$point.0"}}},
 		},
 	}
 	b, err := Marshal(&test)
 	require.NoError(t, err)
 	expected :=
 		`point {
-  z = [a]
+  z = [point.1]
 }
 point {
-  z = [b]
+  z = [point.0]
 }
 `
 	require.Equal(t, expected, string(b))
diff --git a/schemahcl/spec_test.go b/schemahcl/spec_test.go
new file mode 100644
index 00000000..79f30f99
--- /dev/null
+++ b/schemahcl/spec_test.go
@@ -0,0 +1,150 @@
+// 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 schemahcl
+
+import (
+	"strconv"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestRef_Path(t *testing.T) {
+	tests := []struct {
+		ref      string
+		wantPath []PathIndex
+		wantErr  bool
+	}{
+		{
+			ref:     "invalid",
+			wantErr: true, // invalid path
+		},
+		{
+			ref:     "$schema",
+			wantErr: true, // missing identifier
+		},
+		{
+			ref:     "$schema.",
+			wantErr: true, // empty identifier
+		},
+		{
+			ref:     `$schema[""].foo`,
+			wantErr: true, // empty identifier
+		},
+		{
+			ref:     "$.main",
+			wantErr: true, // empty type
+		},
+		{
+			ref: "$schema.main",
+			wantPath: []PathIndex{
+				{T: "schema", V: []string{"main"}},
+			},
+		},
+		{
+			ref: "$schema.main.$table.users",
+			wantPath: []PathIndex{
+				{T: "schema", V: []string{"main"}},
+				{T: "table", V: []string{"users"}},
+			},
+		},
+		{
+			ref: "$schema.main.$table.global.users",
+			wantPath: []PathIndex{
+				{T: "schema", V: []string{"main"}},
+				{T: "table", V: []string{"global", "users"}},
+			},
+		},
+		{
+			ref: "$schema.main.$table.global.users.$column.name",
+			wantPath: []PathIndex{
+				{T: "schema", V: []string{"main"}},
+				{T: "table", V: []string{"global", "users"}},
+				{T: "column", V: []string{"name"}},
+			},
+		},
+		{
+			ref: `$account["ariga.cloud"]["a8m.dev"]`,
+			wantPath: []PathIndex{
+				{T: "account", V: []string{"ariga.cloud", "a8m.dev"}},
+			},
+		},
+		{
+			ref: `$foo.bar["baz"].qux.$bar.baz["qux"].$baz.qux`,
+			wantPath: []PathIndex{
+				{T: "foo", V: []string{"bar", "baz", "qux"}},
+				{T: "bar", V: []string{"baz", "qux"}},
+				{T: "baz", V: []string{"qux"}},
+			},
+		},
+		{
+			ref: `$foo["bar"]["baz"]["qux"].$bar["baz"]["qux"].$baz["qux"]`,
+			wantPath: []PathIndex{
+				{T: "foo", V: []string{"bar", "baz", "qux"}},
+				{T: "bar", V: []string{"baz", "qux"}},
+				{T: "baz", V: []string{"qux"}},
+			},
+		},
+	}
+	for i, tt := range tests {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			r := &Ref{V: tt.ref}
+			path, err := r.Path()
+			require.Equal(t, tt.wantErr, err != nil, err)
+			require.Equal(t, tt.wantPath, path)
+		})
+	}
+}
+
+func TestBuildRef(t *testing.T) {
+	tests := []struct {
+		path    []PathIndex
+		wantRef string
+	}{
+		{
+			path:    []PathIndex{{T: "schema"}, {T: "table"}},
+			wantRef: `$schema.$table`,
+		},
+		{
+			path:    []PathIndex{{T: "schema", V: []string{"main"}}},
+			wantRef: `$schema.main`,
+		},
+		{
+			path: []PathIndex{
+				{T: "schema", V: []string{"main"}},
+				{T: "table", V: []string{"users"}},
+			},
+			wantRef: `$schema.main.$table.users`,
+		},
+		{
+			path: []PathIndex{
+				{T: "schema", V: []string{"main"}},
+				{T: "table", V: []string{"main", "users"}},
+			},
+			wantRef: `$schema.main.$table.main.users`,
+		},
+		{
+			path: []PathIndex{
+				{T: "schema", V: []string{"main"}},
+				{T: "table", V: []string{"main", "foo.bar"}},
+			},
+			wantRef: `$schema.main.$table.main["foo.bar"]`,
+		},
+		{
+			path: []PathIndex{
+				{T: "schema", V: []string{"schema-name"}},
+				{T: "table", V: []string{"other.schema", "foo.bar"}},
+				{T: "column", V: []string{"column-name"}},
+			},
+			wantRef: `$schema.schema-name.$table["other.schema"]["foo.bar"].$column.column-name`,
+		},
+	}
+	for i, tt := range tests {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			ref := BuildRef(tt.path)
+			require.Equal(t, tt.wantRef, ref.V)
+		})
+	}
+}
diff --git a/sql/mysql/sqlspec_test.go b/sql/mysql/sqlspec_test.go
index 4512850b..5451f79f 100644
--- a/sql/mysql/sqlspec_test.go
+++ b/sql/mysql/sqlspec_test.go
@@ -1404,3 +1404,91 @@ schema "s2" {
 `,
 		string(got))
 }
+
+func TestUnmarshalSpec_QuotedIdentifiers(t *testing.T) {
+	var (
+		r schema.Realm
+		s = []byte(`
+schema "a8m.schema" {}
+table "a8m.table" {
+  schema = schema["a8m.schema"]
+  column "a8m.column" {
+    type = int
+  }
+}
+
+schema "nati.schema" {}
+table "nati.schema" "nati.table" {
+  schema = schema["nati.schema"]
+  column "nati.column" {
+    type = int
+  }
+  foreign_key "nati.fk" {
+    columns = [column["nati.column"]]
+	ref_columns = [table["a8m.table"].column["a8m.column"]]
+  }
+}
+`)
+	)
+	require.NoError(t, EvalHCLBytes(s, &r, nil))
+	require.Len(t, r.Schemas, 2)
+	s1, ok := r.Schema("a8m.schema")
+	require.True(t, ok)
+	require.Equal(t, "a8m.schema", s1.Name)
+	require.Len(t, r.Schemas[0].Tables, 1)
+	require.Equal(t, "a8m.table", s1.Tables[0].Name)
+	require.Len(t, r.Schemas[0].Tables[0].Columns, 1)
+	require.Equal(t, "a8m.column", s1.Tables[0].Columns[0].Name)
+	s2, ok := r.Schema("nati.schema")
+	require.True(t, ok)
+	require.Equal(t, "nati.schema", s2.Name)
+	require.Len(t, r.Schemas[1].Tables, 1)
+	require.Equal(t, "nati.table", s2.Tables[0].Name)
+	require.Len(t, r.Schemas[1].Tables[0].Columns, 1)
+	require.Equal(t, "nati.column", s2.Tables[0].Columns[0].Name)
+	require.Len(t, r.Schemas[1].Tables[0].ForeignKeys, 1)
+	require.Equal(t, "nati.fk", s2.Tables[0].ForeignKeys[0].Symbol)
+	require.Len(t, r.Schemas[1].Tables[0].ForeignKeys[0].Columns, 1)
+	require.Equal(t, "nati.column", s2.Tables[0].ForeignKeys[0].Columns[0].Name)
+	require.Len(t, r.Schemas[1].Tables[0].ForeignKeys[0].RefColumns, 1)
+	require.Equal(t, "a8m.column", s2.Tables[0].ForeignKeys[0].RefColumns[0].Name)
+}
+
+func TestMarshalSpec_QuotedIdentifiers(t *testing.T) {
+	s1 := schema.New("a8m.schema").
+		AddTables(schema.NewTable("a8m.table").
+			AddColumns(schema.NewIntColumn("a8m.column", "int")))
+	s2 := schema.New("nati.schema").
+		AddTables(schema.NewTable("nati.table").
+			AddColumns(schema.NewIntColumn("nati.column", "int")).
+			AddForeignKeys(schema.NewForeignKey("nati.fk").
+				AddColumns(s1.Tables[0].Columns[0]).
+				SetRefTable(s1.Tables[0]).
+				AddRefColumns(s1.Tables[0].Columns[0])))
+	r := schema.NewRealm(s1, s2)
+	got, err := MarshalHCL.MarshalSpec(r)
+	require.NoError(t, err)
+	require.Equal(t, `table "a8m.table" {
+  schema = schema["a8m.schema"]
+  column "a8m.column" {
+    null = false
+    type = int
+  }
+}
+table "nati.table" {
+  schema = schema["nati.schema"]
+  column "nati.column" {
+    null = false
+    type = int
+  }
+  foreign_key "nati.fk" {
+    columns     = [column["a8m.column"]]
+    ref_columns = [table["a8m.table"].column["a8m.column"]]
+  }
+}
+schema "a8m.schema" {
+}
+schema "nati.schema" {
+}
+`, string(got))
+}
diff --git a/sql/postgres/sqlspec_test.go b/sql/postgres/sqlspec_test.go
index 2ca88c16..d50866bb 100644
--- a/sql/postgres/sqlspec_test.go
+++ b/sql/postgres/sqlspec_test.go
@@ -481,7 +481,7 @@ table "logs" {
 				}
 			}
 		`), &schema.Schema{}, nil)
-		require.EqualError(t, err, "missing attribute logs.partition.type")
+		require.EqualError(t, err, `specutil: cannot convert table "logs": missing attribute logs.partition.type`)
 
 		err = EvalHCLBytes([]byte(`
 			schema "test" {}
@@ -493,7 +493,7 @@ table "logs" {
 				}
 			}
 		`), &schema.Schema{}, nil)
-		require.EqualError(t, err, `missing columns or expressions for logs.partition`)
+		require.EqualError(t, err, `specutil: cannot convert table "logs": missing columns or expressions for logs.partition`)
 
 		err = EvalHCLBytes([]byte(`
 			schema "test" {}
@@ -507,7 +507,7 @@ table "logs" {
 				}
 			}
 		`), &schema.Schema{}, nil)
-		require.EqualError(t, err, `multiple definitions for logs.partition, use "columns" or "by"`)
+		require.EqualError(t, err, `specutil: cannot convert table "logs": multiple definitions for logs.partition, use "columns" or "by"`)
 	})
 }