fix(search): preserve query value case for case-sensitive fields on OpenSearch

OpenSearch lowercased every KQL query value, so exact-match queries on
case-preserved keyword fields (facet values, ids) never matched their stored
token. Fold the value only for fields with a lowercasing analyzer, mirroring the
bleve backend. The field set is derived once in search.LowercaseValueFields and
shared by both backends (bleve's local buildLowercaseFields is dropped).
This commit is contained in:
Dominik Schmidt
2026-07-29 16:12:07 +02:00
parent 831f8cf773
commit 54f0d80e02
4 changed files with 49 additions and 39 deletions

View File

@@ -3,10 +3,10 @@ package convert
import (
"fmt"
"reflect"
"slices"
"strings"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func ExpandKQL(nodes []ast.Node) ([]ast.Node, error) {
@@ -94,10 +94,12 @@ func (_ kqlExpander) remapKey(current string, defaultKey string) string {
}
func (_ kqlExpander) lowerValue(key, value string) string {
if slices.Contains([]string{"Hidden"}, key) {
return value // ignore certain keys and return the original value
// only fold the value for fields whose index analyzer lowercases too;
// case-preserved (keyword) fields must keep their casing or they never match
// their stored token. Shared with bleve via search.LowercaseValueFields.
if _, ok := search.LowercaseValueFields()[key]; !ok {
return value
}
return strings.ToLower(value)
}

View File

@@ -244,7 +244,22 @@ func TestExpandKQLAST(t *testing.T) {
t.Run("lowercases some values", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "!Hidden: StringNode -> stringnode",
Name: "lowercase-analyzed field: value is folded",
Got: []ast.Node{
ast.StringNode{Key: "Name", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "Name", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Name", Value: "stringnode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "stringnode"},
}},
},
},
{
Name: "case-preserved field: value keeps its casing",
Got: []ast.Node{
ast.StringNode{Key: "aBc", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
@@ -252,24 +267,9 @@ func TestExpandKQLAST(t *testing.T) {
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
&ast.StringNode{Key: "aBc", Value: "StringNode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
}},
},
},
{
Name: "Hidden: StringNode -> StringNode",
Got: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
&ast.StringNode{Key: "aBc", Value: "StringNode"},
}},
},
},

View File

@@ -8,26 +8,13 @@ import (
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// lowercaseFields is derived from Resource.SearchFieldOverrides(): any
// field whose override picks a lowercasing analyzer (`lowercaseKeyword`)
// or the fulltext type (which uses a lowercasing analyzer under the hood)
// gets its query-side value pre-lowercased so compile-time matches the
// index-time tokenization. Anything else keeps its original casing.
var lowercaseFields = buildLowercaseFields()
func buildLowercaseFields() map[string]struct{} {
out := map[string]struct{}{}
for key, opts := range (search.Resource{}).SearchFieldOverrides() {
if opts.Analyzer == "lowercaseKeyword" || opts.Type == mapping.TypeFulltext {
out[key] = struct{}{}
}
}
return out
}
// lowercaseFields holds the fields whose query-side value is pre-lowercased so
// it matches the index-time lowercasing analyzer. Shared with the OpenSearch
// backend via search.LowercaseValueFields; every other field keeps its casing.
var lowercaseFields = search.LowercaseValueFields()
var _fields = map[string]string{
"rootid": "RootID",

View File

@@ -88,6 +88,27 @@ func (Resource) SearchFieldOverrides() map[string]mapping.FieldOpts {
return resourceFieldOverrides()
}
// lowercaseValueFields is the set of index field names whose query values must
// be lowercased to match their index-time lowercasing analyzer (lowercaseKeyword
// or the fulltext type). Built once from the field overrides.
var lowercaseValueFields = sync.OnceValue(func() map[string]struct{} {
out := map[string]struct{}{}
for key, opts := range resourceFieldOverrides() {
if opts.Analyzer == "lowercaseKeyword" || opts.Type == mapping.TypeFulltext {
out[key] = struct{}{}
}
}
return out
})
// LowercaseValueFields returns the set of index field names whose query values
// must be lowercased so query-side matching lines up with the index-time
// analyzer. Both search backends use it, so value casing stays consistent; every
// other (case-preserved) field keeps its original case. Read-only, do not mutate.
func LowercaseValueFields() map[string]struct{} {
return lowercaseValueFields()
}
// ResolveReference makes sure the path is relative to the space root
func ResolveReference(ctx context.Context, ref *provider.Reference, ri *provider.ResourceInfo, gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) (*provider.Reference, error) {
if ref.GetResourceId().GetOpaqueId() == ref.GetResourceId().GetSpaceId() {