fix(search): nest json-tagged embedded structs instead of flattening them

resolveField marked every anonymous field embedded, so walkFields (mapping, field index, validate) and fillStruct (deserializer) flattened a json-tagged embedded struct, while conversions.To/encoding/json on the write path nests it under the tag, mapping and deserializing it at the wrong path. An anonymous field is now embedded only without a json tag name, matching encoding/json; fillStruct also recurses into a value nested struct. No current type has a tagged embedded struct, so runtime behavior is unchanged; this hardens the reflection walker.
This commit is contained in:
Dominik Schmidt committed 2026-08-31 13:40:42 +02:00
1 parent dad99e85b2
commit 69c517a8b2
4 files changed
+56 -3

No files matched your search

@@ -87,6 +87,14 @@ func fillStruct[V any](v reflect.Value, fields map[string]V, prefix string, setL
}
}
// Value nested struct (e.g. a tagged embedded struct): recurse under key.
if fv.Kind() == reflect.Struct && fv.Type() != timeType && fv.Type() != timestampType {
if fillStruct(fv, fields, key, setLeaf) {
touched = true
}
continue
}
if raw, ok := fields[key]; ok && setLeaf(fv, raw) == nil {
touched = true
}
@@ -32,6 +32,13 @@ type embedded struct {
Photo *photo `json:"photo,omitempty"`
}
// taggedEmbedded embeds Leaf with a json tag, so encoding/json nests it under
// "leaf" rather than flattening it onto the parent.
type taggedEmbedded struct {
Leaf `json:"leaf"`
Top string `json:"top"`
}
var _ = Describe("Deserialize", func() {
It("panics for a non-struct type at a prefix", func() {
Expect(func() {
@@ -115,4 +122,18 @@ var _ = Describe("Deserialize", func() {
Expect(r.Year).ToNot(BeNil())
Expect(*r.Year).To(Equal(int32(2024)))
})
It("nests a tagged embedded struct instead of flattening it", func() {
// matches encoding/json: a tagged embedded struct is read under its tag.
r := Deserialize[taggedEmbedded](map[string]any{
"leaf.Name": "n",
"top": "t",
})
Expect(r.Leaf.Name).To(Equal("n"))
Expect(r.Top).To(Equal("t"))
// the flattened top-level key must NOT populate the nested field.
flat := Deserialize[taggedEmbedded](map[string]any{"Name": "flat"})
Expect(flat.Leaf.Name).To(BeEmpty())
})
})
@@ -77,3 +77,22 @@ func TestFieldNameIndex_CoversAllTopLevelFields(t *testing.T) {
require.Equalf(t, want, resolve(idx, in), "derived should cover %q", in)
}
}
// NestInner is embedded with a json tag below, so it must nest, not flatten.
type NestInner struct {
A string `json:"A"`
}
type taggedOuter struct {
NestInner `json:"inner"`
Top string `json:"top"`
}
// A json-tagged embedded struct nests under its tag in the derived index too
// (walkFields must match encoding/json), so its fields are "inner.A", not "A".
func TestFieldNameIndex_TaggedEmbeddedNests(t *testing.T) {
idx := mapping.FieldNameIndex(reflect.TypeFor[taggedOuter](), nil)
require.Equal(t, "inner.A", resolve(idx, "inner.a")) // nested under the tag
require.Equal(t, "top", resolve(idx, "top"))
require.Equal(t, "a", resolve(idx, "a")) // not flattened: bare "a" is not a key
}
+8 -3
View File
@@ -58,6 +58,7 @@ func resolveField(sf reflect.StructField) fieldInfo {
return fieldInfo{Skip: true}
}
name := sf.Name
named := false
tag := sf.Tag.Get("json")
if tag != "" {
first, _, _ := strings.Cut(tag, ",")
@@ -66,12 +67,16 @@ func resolveField(sf reflect.StructField) fieldInfo {
}
if first != "" {
name = first
named = true
}
}
return fieldInfo{
Name: name,
GoField: sf,
Embedded: sf.Anonymous,
Name: name,
GoField: sf,
// An anonymous field is embedded (flattened onto the parent) only when it
// has no json tag name, matching encoding/json: a tag name nests it as a
// regular field instead.
Embedded: sf.Anonymous && !named,
}
}