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.
This commit is contained in:
Dominik Schmidt committed 2026-08-31 13:40:42 +02:00
1 parent c94059fb85
commit fc41995a47
2 files changed
+48 -9

No files matched your search

+24 -9
View File
@@ -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 <field>_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
@@ -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())
})
})