Repository: goadesign/goa (go)

Issue:
v3.5.3 fails to codegen nested result types with multiple MapOf attributes
design at `$GOPATH/src/foo/design/api.go`:
```
package design

import (
	. "goa.design/goa/v3/dsl"
)

var _ = API("Foo", func() {
	Title("Foo")
})

var _ = Service("Bar", func() {
	HTTP(func() {
		Path("/foo/bar")
	})

	Method("show", func() {
		Payload(func() {
			Attribute("id", String)
			Required("id")
		})

		Result(MyResultType)
		Error("not_found")
		HTTP(func() {
			GET("/{id}")
			Response(StatusOK)
			Response("not_found", StatusNotFound)
		})
	})
})

var MyResultType = ResultType("application/vnd.my.result.type", func() {
	TypeName("MyResultType")
	Attribute("inner", InnerType)
})

var InnerType = Type("InnerType", func() {
	Attribute("aaa", MapOf(String, Any))
	Attribute("bbb", String)
	Attribute("ccc", Int)
	Attribute("ddd", MapOf(String, ArrayOf(String)))
})
```
if you codegen with previous version v3.5.2 it correctly produces a working `gen/http/bar/server/types.go`:
```
...
// InnerTypeResponseBody is used to define fields on response body types.
type InnerTypeResponseBody struct {
	Aaa map[string]interface{} `form:"aaa,omitempty" json:"aaa,omitempty" xml:"aaa,omitempty"`
	Bbb *string                `form:"bbb,omitempty" json:"bbb,omitempty" xml:"bbb,omitempty"`
	Ccc *int                   `form:"ccc,omitempty" json:"ccc,omitempty" xml:"ccc,omitempty"`
	Ddd map[string][]string    `form:"ddd,omitempty" json:"ddd,omitempty" xml:"ddd,omitempty"`
}

// NewShowResponseBody builds the HTTP response body from the result of the
// "show" endpoint of the "Bar" service.
func NewShowResponseBody(res *barviews.MyResultTypeView) *ShowResponseBody {
	body := &ShowResponseBody{}
	if res.Inner != nil {
		body.Inner = marshalBarviewsInnerTypeViewToInnerTypeResponseBody(res.Inner)
	}
	return body
}
```
but then if you upgrade to v3.5.3 it fails to codegen:
```
~/gopath/src/foo$ go get -u goa.design/goa/v3/cmd/goa@v3.5.3
go get: upgraded goa.design/goa/v3 v3.5.2 => v3.5.3

~/gopath/src/foo$ goa gen foo/design -o .
v.Ddd[*] is an array but res.Ddd[*] type is any
gen/bar/client.go
gen/bar/endpoints.go
gen/bar/service.go
gen/bar/views/view.go
gen/http/bar/client/cli.go
gen/http/bar/client/client.go
gen/http/bar/client/encode_decode.go
gen/http/bar/client/paths.go
gen/http/bar/client/types.go
gen/http/bar/server/encode_decode.go
gen/http/bar/server/paths.go
gen/http/bar/server/server.go
gen/http/bar/server/types.go
gen/http/cli/foo/cli.go
gen/http/openapi.json
gen/http/openapi.yaml
gen/http/openapi3.json
gen/http/openapi3.yaml
```
I think it is confusing the two different MapOf attributes, "aaa" and "ddd". The result is:
```
// InnerTypeResponseBody is used to define fields on response body types.
type InnerTypeResponseBody struct {
	Aaa map[string]interface{} `form:"aaa,omitempty" json:"aaa,omitempty" xml:"aaa,omitempty"`
	Bbb *string                `form:"bbb,omitempty" json:"bbb,omitempty" xml:"bbb,omitempty"`
	Ccc *int                   `form:"ccc,omitempty" json:"ccc,omitempty" xml:"ccc,omitempty"`
	Ddd map[string]interface{} `form:"ddd,omitempty" json:"ddd,omitempty" xml:"ddd,omitempty"`
	  <--- TYPE WAS CHANGED from map[string][]string in v3.5.2 to map[string]interface{} in v3.5.3
}

// NewShowResponseBody builds the HTTP response body from the result of the
// "show" endpoint of the "Bar" service.
func NewShowResponseBody(res *barviews.MyResultTypeView) *ShowResponseBody {

	return body  <--- UNDEFINED
}
```
The nesting of result type and inner type appear to be necessary to reproduce the issue.

Pull request:
Fix nested type hashing
Cleanup and refactor the various data type hashing algo used by codegen.
Properly handle the case where an attribute is a map or array with user
type elements when computing the hash used by the transform code.

Fix #2970

Patch:
diff --git a/Makefile b/Makefile
index 233c5864..2b433f35 100644
--- a/Makefile
+++ b/Makefile
@@ -74,7 +74,7 @@ ifneq ($(GOOS),windows)
 endif
 
 test:
-	env GO111MODULE=on go test ./...
+	go test ./...
 
 release: release-goa release-examples release-plugins
 
diff --git a/expr/attribute.go b/expr/attribute.go
index 198713fe..0c261530 100644
--- a/expr/attribute.go
+++ b/expr/attribute.go
@@ -543,12 +543,20 @@ func (a *AttributeExpr) debug(prefix string, seen map[*AttributeExpr]int, indent
 	tab := "    "
 	tabs := strings.Repeat(tab, indent)
 	prefix = tabs + prefix
-	if c, ok := seen[a]; ok && c > 1 {
-		fmt.Printf("%s: ...\n", prefix)
-		return
+	if IsObject(a.Type) {
+		// avoid infinite recursion
+		if c, ok := seen[a]; ok && c > 1 {
+			fmt.Printf("%s: ...\n", prefix)
+			return
+		}
+		seen[a]++
+	}
+	n := a.Type.Name()
+	if desc := a.Description; desc != "" {
+		fmt.Printf("%s: %s (%s)\n", prefix, n, desc)
+	} else {
+		fmt.Printf("%s: %s\n", prefix, n)
 	}
-	seen[a]++
-	fmt.Printf("%s: %s\n", prefix, a.Type.Name())
 	if o := AsObject(a.Type); o != nil {
 		for _, att := range *o {
 			att.Attribute.debug("- "+att.Name, seen, indent+1)
diff --git a/expr/hasher.go b/expr/hasher.go
new file mode 100644
index 00000000..5e44087b
--- /dev/null
+++ b/expr/hasher.go
@@ -0,0 +1,119 @@
+package expr
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+)
+
+var (
+	arrayPrefix         = "_a_"
+	attributePrefix     = "-"
+	attributeTypePrefix = "/"
+	mapElemPrefix       = ":"
+	mapPrefix           = "_m_"
+	objectPrefix        = "_o_"
+	tagPrefix           = "+"
+	userTypeHashPrefix  = "!"
+	userTypePrefix      = "_u_"
+)
+
+// Hash returns a hash value for the given data type. Two types have the same
+// hash if:
+//    - both types have the same kind
+//    - array types have elements whose types have the same hash
+//    - map types have keys and elements whose types have the same hash
+//    - user types have the same name if ignoreNames is false or ignoreFields is true
+//    - user types have the same attribute names and the attribute types have the same hash if ignoreFields is false
+//    - object attributes have the same "struct:field:xxx" tags if ignoreTags is false
+//
+func Hash(dt DataType, ignoreFields, ignoreNames, ignoreTags bool) string {
+	return *hash(dt, ignoreFields, ignoreNames, ignoreTags, make(map[*Object]*string))
+}
+
+func hash(dt DataType, ignoreFields, ignoreNames, ignoreTags bool, seen map[*Object]*string) *string {
+	if seen == nil {
+		seen = make(map[*Object]*string)
+	}
+	switch dt.Kind() {
+	case BooleanKind, IntKind, Int32Kind, Int64Kind, UIntKind, UInt32Kind, UInt64Kind, Float32Kind, Float64Kind, StringKind, BytesKind, AnyKind:
+		n := dt.Name()
+		return &n
+	case ArrayKind:
+		return hashArray(dt.(*Array), ignoreFields, ignoreNames, ignoreTags, seen)
+	case MapKind:
+		return hashMap(dt.(*Map), ignoreFields, ignoreNames, ignoreTags, seen)
+	case UserTypeKind, ResultTypeKind:
+		return hashUserType(dt.(UserType), ignoreFields, ignoreNames, ignoreTags, seen)
+	case ObjectKind:
+		return hashObject(dt.(*Object), ignoreFields, ignoreNames, ignoreTags, seen)
+	default:
+		panic(fmt.Sprintf("invalid type for hashing: %T", dt))
+	}
+}
+
+func hashArray(a *Array, ignoreFields, ignoreNames, ignoreTags bool, seen map[*Object]*string) *string {
+	h := arrayPrefix + *hash(a.ElemType.Type, ignoreFields, ignoreNames, ignoreTags, seen)
+	return &h
+}
+
+func hashMap(m *Map, ignoreFields, ignoreNames, ignoreTags bool, seen map[*Object]*string) *string {
+	h := mapPrefix + *hash(m.KeyType.Type, ignoreFields, ignoreNames, ignoreTags, seen) +
+		mapElemPrefix + *hash(m.ElemType.Type, ignoreFields, ignoreNames, ignoreTags, seen)
+	return &h
+}
+
+func hashUserType(ut UserType, ignoreFields, ignoreNames, ignoreTags bool, seen map[*Object]*string) *string {
+	h := userTypePrefix
+	if !ignoreNames || ignoreFields {
+		h += ut.Name()
+	}
+	if ignoreFields {
+		return &h
+	}
+	att := ut.Attribute()
+	if !ignoreTags {
+		for k, v := range att.Meta {
+			if !strings.HasPrefix(k, "struct:field:") {
+				continue
+			}
+			h += fmt.Sprintf("%s%s%s", tagPrefix, k, v)
+		}
+	}
+	h += userTypeHashPrefix + *hash(att.Type, ignoreFields, ignoreNames, ignoreTags, seen)
+	return &h
+}
+
+func hashObject(o *Object, ignoreFields, ignoreNames, ignoreTags bool, seen map[*Object]*string) *string {
+	if s, ok := seen[o]; ok {
+		return s
+	}
+	h := objectPrefix
+	ph := &h
+	seen[o] = ph
+	for _, a := range sorted(o) {
+		*ph += attributePrefix + a.Name +
+			attributeTypePrefix + *hash(a.Attribute.Type, ignoreFields, ignoreNames, ignoreTags, seen)
+		if !ignoreTags {
+			for k, v := range a.Attribute.Meta {
+				if !strings.HasPrefix(k, "struct:field:") {
+					continue
+				}
+				*ph += fmt.Sprintf("%s%s%s", tagPrefix, k, v)
+			}
+		}
+	}
+	return ph
+}
+
+func sorted(o *Object) Object {
+	if o == nil {
+		return nil
+	}
+	s := make([]*NamedAttributeExpr, len(*o))
+	for i, a := range *o {
+		s[i] = a
+	}
+	sort.Slice(s, func(i, j int) bool { return s[i].Name < s[j].Name })
+	return Object(s)
+}
diff --git a/expr/result_type.go b/expr/result_type.go
index f2379b8f..0a8761d2 100644
--- a/expr/result_type.go
+++ b/expr/result_type.go
@@ -224,22 +224,20 @@ func (m *ResultTypeExpr) ensureDefaultView() {
 // or any result type that makes up its attributes recursively. Note that
 // individual attributes may use a different view. In this case Project uses
 // that view and returns an error if it isn't defined on the attribute type.
-func Project(m *ResultTypeExpr, view string, seen ...map[string]*AttributeExpr) (*ResultTypeExpr, error) {
+func Project(m *ResultTypeExpr, view string) (*ResultTypeExpr, error) {
+	return project(m, view, make(map[string]*AttributeExpr))
+}
+
+func project(m *ResultTypeExpr, view string, seen map[string]*AttributeExpr) (*ResultTypeExpr, error) {
 	_, params, _ := mime.ParseMediaType(m.Identifier)
 	if params["view"] == view {
 		// nothing to do
 		return m, nil
 	}
 	if _, ok := m.Type.(*Array); ok {
-		return projectCollection(m, view, seen...)
-	}
-	var s map[string]*AttributeExpr
-	if len(seen) > 0 {
-		s = seen[0]
-	} else {
-		s = make(map[string]*AttributeExpr)
+		return projectCollection(m, view, seen)
 	}
-	return projectSingle(m, view, s)
+	return projectSingle(m, view, seen)
 }
 
 func projectSingle(m *ResultTypeExpr, view string, seen map[string]*AttributeExpr) (*ResultTypeExpr, error) {
@@ -276,7 +274,7 @@ func projectSingle(m *ResultTypeExpr, view string, seen map[string]*AttributeExp
 	}
 
 	var ut *UserTypeExpr
-	if att, ok := seen[hash(m.Attribute(), view)]; ok {
+	if att, ok := seen[hashAttrAndView(m.Attribute(), view)]; ok {
 		if rt, ok2 := att.Type.(*ResultTypeExpr); ok2 {
 			ut = &UserTypeExpr{
 				AttributeExpr: DupAtt(rt.Attribute()),
@@ -321,10 +319,10 @@ func projectSingle(m *ResultTypeExpr, view string, seen map[string]*AttributeExp
 	return projected, nil
 }
 
-func projectCollection(m *ResultTypeExpr, view string, seen ...map[string]*AttributeExpr) (*ResultTypeExpr, error) {
+func projectCollection(m *ResultTypeExpr, view string, seen map[string]*AttributeExpr) (*ResultTypeExpr, error) {
 	// Project the collection element result type
 	e := m.Type.(*Array).ElemType.Type.(*ResultTypeExpr) // validation checked this cast would work
-	pe, err2 := Project(e, view, seen...)
+	pe, err2 := project(e, view, seen)
 	if err2 != nil {
 		return nil, fmt.Errorf("collection element: %s", err2)
 	}
@@ -358,7 +356,7 @@ func projectCollection(m *ResultTypeExpr, view string, seen ...map[string]*Attri
 }
 
 func projectRecursive(at *AttributeExpr, vat *NamedAttributeExpr, view string, seen map[string]*AttributeExpr) (*AttributeExpr, error) {
-	if att, ok := seen[hash(at, view)]; ok {
+	if att, ok := seen[hashAttrAndView(at, view)]; ok {
 		return att, nil
 	}
 	at = DupAtt(at)
@@ -375,8 +373,8 @@ func projectRecursive(at *AttributeExpr, vat *NamedAttributeExpr, view string, s
 		if view == "" {
 			view = DefaultView
 		}
-		seen[hash(at, view)] = at
-		pr, err := Project(rt, view, seen)
+		seen[hashAttrAndView(at, view)] = at
+		pr, err := project(rt, view, seen)
 		if err != nil {
 			return nil, fmt.Errorf("view %#v on field %#v cannot be computed: %s", view, vat.Name, err)
 		}
@@ -384,7 +382,9 @@ func projectRecursive(at *AttributeExpr, vat *NamedAttributeExpr, view string, s
 		return at, nil
 	}
 
-	seen[hash(at, view)] = at
+	if _, ok := at.Type.(*UserTypeExpr); ok {
+		seen[hashAttrAndView(at, view)] = at
+	}
 
 	if obj := AsObject(at.Type); obj != nil {
 		vobj := AsObject(vat.Attribute.Type)
@@ -452,44 +452,8 @@ func (v *ViewExpr) EvalName() string {
 	return prefix + suffix
 }
 
-// hash computes a hash for an attribute and a view that returns the same value
-// for two attributes and views that produce the same projected type.
-func hash(att *AttributeExpr, view string) string {
-	sep := "!"
-	ut, ok := att.Type.(UserType)
-	if !ok {
-		return att.Type.Name() + sep + view
-	}
-	// elems contain the elements that affect how types are projected:
-	// - the type identifier
-	// - the view
-	// - the type attribute tags (if any)
-	elems := []string{ut.ID(), view}
-
-	add := func(att *AttributeExpr) {
-		for k, v := range att.Meta {
-			elems = append(elems, k)
-			elems = append(elems, v...)
-		}
-	}
-
-	add(att)
-	if obj := AsObject(ut); obj != nil {
-		for _, nat := range *obj {
-			add(nat.Attribute)
-		}
-	}
-	n := len(sep) * (len(elems) - 1)
-	for i := 0; i < len(elems); i++ {
-		n += len(elems[i])
-	}
-
-	var b strings.Builder
-	b.Grow(n)
-	b.WriteString(elems[0])
-	for _, s := range elems[1:] {
-		b.WriteString(sep)
-		b.WriteString(s)
-	}
-	return b.String()
+// hashAttrAndView computes a hash for an attribute and a view that returns the
+// same value for two attributes and views that produce the same projected type.
+func hashAttrAndView(att *AttributeExpr, view string) string {
+	return Hash(att.Type, false, false, false) + "::" + view
 }
diff --git a/expr/types.go b/expr/types.go
index d3f349e0..ecd3112b 100644
--- a/expr/types.go
+++ b/expr/types.go
@@ -245,80 +245,11 @@ func IsPrimitive(dt DataType) bool {
 //    - map types have keys and elements whose types are equal
 //    - objects have the same attribute names and the attribute types are equal
 //
-// Note: calling Equal is not equivalent to evaluation dt.Hash() == dt2.Hash()
+// Note: calling Equal is not equivalent to evaluating dt.Hash() == dt2.Hash()
 // as the former may return true for two user types with different names and
 // thus with different hash values.
 func Equal(dt, dt2 DataType) bool {
-	bs := *equal(dt, dt2)
-	for _, b := range bs {
-		if !*b {
-			return false
-		}
-	}
-	return true
-}
-
-// Support recursive types by doing lazy evaluation.
-func equal(dt, dt2 DataType, seen ...map[string]*[]*bool) *[]*bool {
-	f := false
-	fs := []*bool{&f}
-	if dt.Kind() != dt2.Kind() {
-		return &fs
-	}
-	var s map[string]*[]*bool
-	if len(seen) > 0 {
-		s = seen[0]
-	} else {
-		s = make(map[string]*[]*bool)
-	}
-	switch actual := dt.(type) {
-	case *Array:
-		return equal(actual.ElemType.Type, AsArray(dt2).ElemType.Type, s)
-	case *Map:
-		s1 := equal(actual.ElemType.Type, AsMap(dt2).ElemType.Type, s)
-		s2 := equal(actual.KeyType.Type, AsMap(dt2).KeyType.Type, s)
-		s3 := append(*s1, *s2...)
-		return &s3
-	case *Object:
-		if len(*actual) != len(*AsObject(dt2)) {
-			return &fs
-		}
-		var bs []*bool
-		for _, nat := range *actual {
-			obj := AsObject(dt2)
-			at := obj.Attribute(nat.Name)
-			if at == nil {
-				return &fs
-			}
-			bs = append(bs, *equal(nat.Attribute.Type, at.Type, s)...)
-		}
-		return &bs
-	case UserType:
-		key := actual.Name() + "=" + dt2.Name()
-		if v, ok := s[key]; ok {
-			return v
-		}
-		var res []*bool
-		pres := &res
-		s[key] = pres
-		if IsObject(actual) {
-			*pres = *equal(AsObject(dt), AsObject(dt2), s)
-		} else if IsMap(actual) {
-			// Map aliased as UserType
-			*pres = *equal(AsMap(dt), AsMap(dt2), s)
-		} else if IsArray(actual) {
-			// Array aliased as UserType or CollectionOf
-			*pres = *equal(AsArray(dt), AsArray(dt2), s)
-		} else {
-			// Primitive type aliased as UserType
-			*pres = *equal(dt, dt2, s)
-		}
-		return pres
-	}
-
-	t := true
-	ts := []*bool{&t}
-	return &ts
+	return Hash(dt, false, true, true) == Hash(dt2, false, true, true)
 }
 
 // DataType implementation
@@ -428,7 +359,7 @@ func (a *Array) Name() string {
 
 // Hash returns a unique hash value for a.
 func (a *Array) Hash() string {
-	return "_array_+" + a.ElemType.Type.Hash()
+	return Hash(a, true, false, true)
 }
 
 // IsCompatible returns true if val is compatible with p.
@@ -547,11 +478,7 @@ func (o *Object) Name() string { return "object" }
 
 // Hash returns a unique hash value for o.
 func (o *Object) Hash() string {
-	h := "_object_"
-	for _, nat := range *o {
-		h += "+" + nat.Name + "/" + nat.Attribute.Type.Hash()
-	}
-	return h
+	return Hash(o, true, false, true)
 }
 
 // Merge creates a new object consisting of the named attributes of o appended
@@ -590,7 +517,7 @@ func (m *Map) Name() string { return "map" }
 
 // Hash returns a unique hash value for m.
 func (m *Map) Hash() string {
-	return "_map_+" + m.KeyType.Type.Hash() + ":" + m.ElemType.Type.Hash()
+	return Hash(m, true, false, true)
 }
 
 // IsCompatible returns true if o describes the (Go) type of val.
diff --git a/expr/user_type.go b/expr/user_type.go
index ec89bc28..39b0b7d4 100644
--- a/expr/user_type.go
+++ b/expr/user_type.go
@@ -76,7 +76,7 @@ func (u *UserTypeExpr) Dup(att *AttributeExpr) UserType {
 
 // Hash returns a unique hash value for u.
 func (u *UserTypeExpr) Hash() string {
-	return "_type_+" + u.TypeName
+	return Hash(u, true, false, true)
 }
 
 // Example produces an example for the user type which is JSON serialization


Test patch:
diff --git a/expr/hasher_test.go b/expr/hasher_test.go
new file mode 100644
index 00000000..a7eb1a5d
--- /dev/null
+++ b/expr/hasher_test.go
@@ -0,0 +1,90 @@
+package expr
+
+import "testing"
+
+func TestPrimitiveHash(t *testing.T) {
+	cases := map[string]Primitive{
+		"boolean": Boolean,
+		"int":     Int,
+		"int32":   Int32,
+		"int64":   Int64,
+		"uint":    UInt,
+		"uint32":  UInt32,
+		"uint64":  UInt64,
+		"float32": Float32,
+		"float64": Float64,
+		"string":  String,
+		"bytes":   Bytes,
+		"any":     Any,
+	}
+	for k, p := range cases {
+		if actual := Hash(p, true, false, false); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+		if actual := Hash(p, true, false, true); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+		if actual := Hash(p, true, true, false); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+		if actual := Hash(p, true, true, true); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+		if actual := Hash(p, false, false, false); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+		if actual := Hash(p, false, false, true); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+		if actual := Hash(p, false, true, false); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+		if actual := Hash(p, false, true, true); k != actual {
+			t.Errorf("%s: got %#v, expected %#v", k, actual, k)
+		}
+	}
+}
+
+func TestObjectHash(t *testing.T) {
+	var (
+		attributeInt            = &AttributeExpr{Type: Int}
+		attributeString         = &AttributeExpr{Type: String}
+		attributeArray          = &AttributeExpr{Type: &Array{ElemType: attributeString}}
+		attributeMap            = &AttributeExpr{Type: &Map{KeyType: attributeInt, ElemType: attributeString}}
+		userType                = &UserTypeExpr{AttributeExpr: attributeString, TypeName: "quux"}
+		namedAttributePrimitive = &NamedAttributeExpr{Name: "foo", Attribute: attributeInt}
+		namedAttributeArray     = &NamedAttributeExpr{Name: "bar", Attribute: attributeArray}
+		namedAttributeMap       = &NamedAttributeExpr{Name: "baz", Attribute: attributeMap}
+		namedAttributeUserType  = &NamedAttributeExpr{Name: "qux", Attribute: &AttributeExpr{Type: userType}}
+	)
+	cases := map[string]struct {
+		object   Object
+		expected string
+	}{
+		"nil": {
+			object:   nil,
+			expected: "_o_",
+		},
+		"single attribute": {
+			object:   Object{namedAttributePrimitive},
+			expected: "_o_-foo/int",
+		},
+		"multiple attributes": {
+			object: Object{
+				namedAttributePrimitive,
+				namedAttributeArray,
+				namedAttributeMap,
+				namedAttributeUserType,
+			},
+			expected: "_o_-bar/_a_string-baz/_m_int:string-foo/int-qux/_u_quux",
+		},
+	}
+
+	for k, tc := range cases {
+		t.Run(k, func(t *testing.T) {
+			if actual := Hash(&tc.object, true, false, true); actual != tc.expected {
+				t.Errorf("got %#v, expected %#v", actual, tc.expected)
+			}
+		})
+	}
+}
diff --git a/expr/project_test.go b/expr/project_test.go
index 549547c4..6837f133 100644
--- a/expr/project_test.go
+++ b/expr/project_test.go
@@ -1,7 +1,7 @@
 package expr
 
 import (
-	"encoding/json"
+	"fmt"
 	"strings"
 	"testing"
 )
@@ -27,7 +27,8 @@ var (
 	compositeResultDefault = resultType("a", object(collectionResultDefault), "b", String)
 	compositeResultLink    = resultType("a", object(collectionResultLink))
 
-	recursiveResult = resultRecursive("a", String, "r", simpleResult, view("default", "a", String, "r", AsObject(simpleResult)))
+	recursiveResult         = resultRecursive("a", String, view("default", "a", object(String)))
+	embeddedRecursiveResult = resultType("a", String, "rec", recursiveResult)
 )
 
 func init() {
@@ -49,7 +50,7 @@ func TestProject(t *testing.T) {
 		{"collection-link", collectionResult, "link", collectionResultLink},
 		{"composite-default", compositeResult, "default", compositeResultDefault},
 		{"composite-link", compositeResult, "link", compositeResultLink},
-		{"recursive", recursiveResult, "default", recursiveResult},
+		{"recursive", recursiveResult, "default", embeddedRecursiveResult},
 	}
 	for _, k := range cases {
 		t.Run(k.Name, func(t *testing.T) {
@@ -58,15 +59,20 @@ func TestProject(t *testing.T) {
 				t.Fatal(err)
 			}
 			if !Equal(projected, k.Expected) {
-				pj, err := json.MarshalIndent(projected.Example(testrand), "  ", "  ")
-				if err != nil {
-					t.Fatal(err)
-				}
-				ej, err := json.MarshalIndent(k.Expected.Example(testrand), "  ", "  ")
-				if err != nil {
-					t.Fatal(err)
+				projected.AttributeExpr.Debug("got")
+				k.Expected.AttributeExpr.Debug("expected")
+				t.Errorf("got: %s, expected: %s\n", Hash(projected, false, true, true), Hash(k.Expected, false, true, true))
+			}
+			if pobj := AsObject(projected.AttributeExpr.Type); pobj != nil {
+				for _, att := range *pobj {
+					att2 := k.Expected.AttributeExpr.Find(att.Name)
+					if att2 == nil {
+						continue
+					}
+					if att.Attribute.Description != att2.Description {
+						t.Errorf("got description %q, expected %q", att.Attribute.Description, att2.Description)
+					}
 				}
-				t.Errorf("projected type\n%s\ndoes not match expectation\n%s", string(pj), string(ej))
 			}
 		})
 	}
@@ -117,9 +123,11 @@ func resultType(params ...interface{}) *ResultTypeExpr {
 		switch pt := p.(type) {
 		case string:
 			obj = append(obj, &NamedAttributeExpr{
-				Name:      params[i].(string),
-				Attribute: &AttributeExpr{Type: params[i+1].(DataType)},
-			})
+				Name: params[i].(string),
+				Attribute: &AttributeExpr{
+					Type:        params[i+1].(DataType),
+					Description: fmt.Sprintf("desc %s", params[i]),
+				}})
 		case *ViewExpr:
 			views = append(views, pt)
 		}
@@ -151,7 +159,7 @@ func collection(elemType *ResultTypeExpr) *ResultTypeExpr {
 
 func resultRecursive(params ...interface{}) *ResultTypeExpr {
 	rt := resultType(params...)
-	recAtt := &NamedAttributeExpr{Name: "rec", Attribute: &AttributeExpr{Type: rt}}
+	recAtt := &NamedAttributeExpr{Name: "rec", Attribute: &AttributeExpr{Type: rt, Description: "desc rec"}}
 	obj := AsObject(rt)
 	*obj = append(*obj, recAtt)
 	for _, v := range rt.Views {
diff --git a/expr/types_test.go b/expr/types_test.go
index 9c2324d5..a9091ca4 100644
--- a/expr/types_test.go
+++ b/expr/types_test.go
@@ -656,68 +656,6 @@ func TestPrimitiveIsCompatible(t *testing.T) {
 	}
 }
 
-func TestPrimitiveHash(t *testing.T) {
-	cases := map[string]struct {
-		p        Primitive
-		expected string
-	}{
-		"boolean": {
-			p:        Boolean,
-			expected: "boolean",
-		},
-		"int": {
-			p:        Int,
-			expected: "int",
-		},
-		"int32": {
-			p:        Int32,
-			expected: "int32",
-		},
-		"int64": {
-			p:        Int64,
-			expected: "int64",
-		},
-		"uint": {
-			p:        UInt,
-			expected: "uint",
-		},
-		"uint32": {
-			p:        UInt32,
-			expected: "uint32",
-		},
-		"uint64": {
-			p:        UInt64,
-			expected: "uint64",
-		},
-		"float32": {
-			p:        Float32,
-			expected: "float32",
-		},
-		"float64": {
-			p:        Float64,
-			expected: "float64",
-		},
-		"string": {
-			p:        String,
-			expected: "string",
-		},
-		"bytes": {
-			p:        Bytes,
-			expected: "bytes",
-		},
-		"any": {
-			p:        Any,
-			expected: "any",
-		},
-	}
-
-	for k, tc := range cases {
-		if actual := tc.p.Hash(); tc.expected != actual {
-			t.Errorf("%s: got %#v, expected %#v", k, actual, tc.expected)
-		}
-	}
-}
-
 func TestArrayIsCompatible(t *testing.T) {
 	var (
 		b  = true
@@ -807,82 +745,6 @@ func TestObjectRename(t *testing.T) {
 	}
 }
 
-func TestObjectHash(t *testing.T) {
-	var (
-		attributeInt = &AttributeExpr{
-			Type: Int,
-		}
-		attributeString = &AttributeExpr{
-			Type: String,
-		}
-		attributeArray = &AttributeExpr{
-			Type: &Array{
-				ElemType: attributeString,
-			},
-		}
-		attributeMap = &AttributeExpr{
-			Type: &Map{
-				KeyType:  attributeInt,
-				ElemType: attributeString,
-			},
-		}
-		userType = &UserTypeExpr{
-			AttributeExpr: attributeString,
-			TypeName:      "quux",
-		}
-		namedAttributePrimitive = &NamedAttributeExpr{
-			Name:      "foo",
-			Attribute: attributeInt,
-		}
-		namedAttributeArray = &NamedAttributeExpr{
-			Name:      "bar",
-			Attribute: attributeArray,
-		}
-		namedAttributeMap = &NamedAttributeExpr{
-			Name:      "baz",
-			Attribute: attributeMap,
-		}
-		namedAttributeUserType = &NamedAttributeExpr{
-			Name: "qux",
-			Attribute: &AttributeExpr{
-				Type: userType,
-			},
-		}
-	)
-	cases := map[string]struct {
-		object   Object
-		expected string
-	}{
-		"nil": {
-			object:   nil,
-			expected: "_object_",
-		},
-		"single attribute": {
-			object: Object{
-				namedAttributePrimitive,
-			},
-			expected: "_object_+foo/int",
-		},
-		"multiple attributes": {
-			object: Object{
-				namedAttributePrimitive,
-				namedAttributeArray,
-				namedAttributeMap,
-				namedAttributeUserType,
-			},
-			expected: "_object_+foo/int+bar/_array_+string+baz/_map_+int:string+qux/_type_+quux",
-		},
-	}
-
-	for k, tc := range cases {
-		t.Run(k, func(t *testing.T) {
-			if actual := tc.object.Hash(); actual != tc.expected {
-				t.Errorf("got %#v, expected %#v", actual, tc.expected)
-			}
-		})
-	}
-}
-
 func TestObjectIsCompatible(t *testing.T) {
 	var (
 		b = true