mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-07-31 17:59:49 -04:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user