diff --git a/services/search/pkg/mapping/deserialize.go b/services/search/pkg/mapping/deserialize.go index f8c2f844a1..a247de659e 100644 --- a/services/search/pkg/mapping/deserialize.go +++ b/services/search/pkg/mapping/deserialize.go @@ -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 } diff --git a/services/search/pkg/mapping/deserialize_test.go b/services/search/pkg/mapping/deserialize_test.go index 55824aeaa5..11c6110d5b 100644 --- a/services/search/pkg/mapping/deserialize_test.go +++ b/services/search/pkg/mapping/deserialize_test.go @@ -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()) + }) }) diff --git a/services/search/pkg/mapping/fieldindex_test.go b/services/search/pkg/mapping/fieldindex_test.go index d0e47dcb2e..56e6b0119b 100644 --- a/services/search/pkg/mapping/fieldindex_test.go +++ b/services/search/pkg/mapping/fieldindex_test.go @@ -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 +} diff --git a/services/search/pkg/mapping/infer.go b/services/search/pkg/mapping/infer.go index 76a49bd669..5524703f8d 100644 --- a/services/search/pkg/mapping/infer.go +++ b/services/search/pkg/mapping/infer.go @@ -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, } }