From fc41995a47f9a94de3d8dba22095fba86279061c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 10 Aug 2026 19:42:08 +0200 Subject: [PATCH] fix(search): validate CaseInsensitive against the effective field type The guard only rejected CaseInsensitive when a non-keyword/path Type was set explicitly. With no Type, isCasedType treated the field as cased, so CaseInsensitive on an inferred numeric/bool/datetime field passed validation but produced no _lowercase sibling, and the query would silently match nothing. Validate now falls back to the inferred Go type. --- services/search/pkg/mapping/validate.go | 33 ++++++++++++++------ services/search/pkg/mapping/validate_test.go | 24 ++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/services/search/pkg/mapping/validate.go b/services/search/pkg/mapping/validate.go index f0a8e462e2..fa6a8b222e 100644 --- a/services/search/pkg/mapping/validate.go +++ b/services/search/pkg/mapping/validate.go @@ -16,17 +16,20 @@ func Validate(t reflect.Type, overrides map[string]FieldOpts) error { if len(overrides) == 0 { return nil } - names := collectNames(t, "") + fields := collectFields(t, "") var unknown, miscased []string for k, opts := range overrides { - if _, ok := names[k]; !ok { + goType, ok := fields[k] + if !ok { unknown = append(unknown, k) continue } // CaseInsensitive routes queries to a _lowercase sibling, which is // only generated for keyword/path fields; on any other type the query - // would target a non-existent field and silently match nothing. - if opts.caseInsensitive() && !isCasedType(opts) { + // would target a non-existent field and silently match nothing. Use the + // effective type (override, else the inferred Go type), since an override + // with no explicit Type still infers keyword/numeric/... from the field. + if opts.caseInsensitive() && !effectivelyCased(opts, goType) { miscased = append(miscased, k) } } @@ -41,17 +44,29 @@ func Validate(t reflect.Type, overrides map[string]FieldOpts) error { return nil } -func collectNames(t reflect.Type, prefix string) map[string]struct{} { - out := map[string]struct{}{} +// effectivelyCased reports whether a field is keyword/path (the only types that +// get a _lowercase sibling), from the override type or the inferred Go type. +func effectivelyCased(opts FieldOpts, goType reflect.Type) bool { + eff := opts.Type + if eff == "" && goType != nil { + eff = inferType(goType) + } + return eff == TypeKeyword || eff == TypePath +} + +// collectFields maps every known field name (nested as "parent.child") to its Go +// type. Embedded structs are flattened, matching encoding/json. +func collectFields(t reflect.Type, prefix string) map[string]reflect.Type { + out := map[string]reflect.Type{} _ = walkFields(t, func(fi fieldInfo) error { key := fi.Name if prefix != "" { key = prefix + "." + fi.Name } - out[key] = struct{}{} + out[key] = fi.GoField.Type if sub := structType(fi.GoField.Type); sub != nil { - for k := range collectNames(sub, key) { - out[k] = struct{}{} + for k, v := range collectFields(sub, key) { + out[k] = v } } return nil diff --git a/services/search/pkg/mapping/validate_test.go b/services/search/pkg/mapping/validate_test.go index 69a96b0a01..7a34183a4b 100644 --- a/services/search/pkg/mapping/validate_test.go +++ b/services/search/pkg/mapping/validate_test.go @@ -54,4 +54,28 @@ var _ = Describe("Validate", func() { Expect(err.Error()).To(ContainSubstring("Name")) Expect(err.Error()).To(ContainSubstring("CaseInsensitive")) }) + + It("rejects CaseInsensitive on an inferred non-keyword field (empty Type)", func() { + True := true + type doc struct { + Size uint64 `json:"Size"` + } + err := Validate(reflect.TypeFor[doc](), map[string]FieldOpts{ + "Size": {CaseInsensitive: &True}, // no explicit Type -> inferred numeric + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Size")) + }) + + It("accepts CaseInsensitive on an inferred keyword field (empty Type)", func() { + True := true + type doc struct { + Name string `json:"Name"` + Tags []string `json:"Tags"` + } + Expect(Validate(reflect.TypeFor[doc](), map[string]FieldOpts{ + "Name": {CaseInsensitive: &True}, + "Tags": {CaseInsensitive: &True}, + })).To(Succeed()) + }) })