From 40a2f1460074b8f91e37c5eb12aba46779574e2a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 29 Jul 2026 16:12:07 +0200 Subject: [PATCH] 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). --- .../opensearch/internal/convert/kql_expand.go | 12 +++++---- services/search/pkg/query/bleve/compiler.go | 21 +++------------ services/search/pkg/search/search.go | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/services/search/pkg/opensearch/internal/convert/kql_expand.go b/services/search/pkg/opensearch/internal/convert/kql_expand.go index 17258512c9..5c63a3072f 100644 --- a/services/search/pkg/opensearch/internal/convert/kql_expand.go +++ b/services/search/pkg/opensearch/internal/convert/kql_expand.go @@ -3,13 +3,13 @@ package convert import ( "fmt" "reflect" - "slices" "strconv" "strings" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/opencloud-eu/opencloud/pkg/ast" + "github.com/opencloud-eu/opencloud/services/search/pkg/search" ) func ExpandKQL(nodes []ast.Node) ([]ast.Node, error) { @@ -101,11 +101,13 @@ func (_ kqlExpander) remapKey(current string, defaultKey string) string { } func (_ kqlExpander) lowerValue(key, value string) string { - if slices.Contains([]string{"Name", "Title", "Tags", "Content", "MimeType", "Type", "Hidden"}, key) { - return strings.ToLower(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 value + return strings.ToLower(value) } func (_ kqlExpander) unfoldValue(key, value string) []ast.Node { diff --git a/services/search/pkg/query/bleve/compiler.go b/services/search/pkg/query/bleve/compiler.go index 218dc43104..9722f45512 100644 --- a/services/search/pkg/query/bleve/compiler.go +++ b/services/search/pkg/query/bleve/compiler.go @@ -12,26 +12,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", diff --git a/services/search/pkg/search/search.go b/services/search/pkg/search/search.go index bf81837d17..fb4ebef31e 100644 --- a/services/search/pkg/search/search.go +++ b/services/search/pkg/search/search.go @@ -89,6 +89,32 @@ 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{}{} + } + } + // stored values are normalized lowercase, so query values must fold too + // even though the index fields preserve case + for _, key := range []string{"MimeType", "Type", "Hidden"} { + 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() {