diff --git a/services/search/pkg/opensearch/internal/convert/kql_expand.go b/services/search/pkg/opensearch/internal/convert/kql_expand.go index 5688f6dfe6..5fd149f81e 100644 --- a/services/search/pkg/opensearch/internal/convert/kql_expand.go +++ b/services/search/pkg/opensearch/internal/convert/kql_expand.go @@ -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) } diff --git a/services/search/pkg/opensearch/internal/convert/kql_expand_test.go b/services/search/pkg/opensearch/internal/convert/kql_expand_test.go index 49218d5aa5..6a62b151a9 100644 --- a/services/search/pkg/opensearch/internal/convert/kql_expand_test.go +++ b/services/search/pkg/opensearch/internal/convert/kql_expand_test.go @@ -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"}, }}, }, }, diff --git a/services/search/pkg/query/bleve/compiler.go b/services/search/pkg/query/bleve/compiler.go index b0fbeb870e..02f5c17992 100644 --- a/services/search/pkg/query/bleve/compiler.go +++ b/services/search/pkg/query/bleve/compiler.go @@ -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", diff --git a/services/search/pkg/search/search.go b/services/search/pkg/search/search.go index 39ac50f704..6b2023b26f 100644 --- a/services/search/pkg/search/search.go +++ b/services/search/pkg/search/search.go @@ -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() {