fix(search): keep mediatype:file atomic so it composes with other terms

mediatype:file expands to a NOT restriction. Spliced inline as `NOT MimeType:httpd/unix-directory`, the bleve compiler's NOT branch left a stale operand, so `mediatype:file AND name:x` dropped `name:x` and matched nothing (the web Files filter). It is now wrapped in a group so the negation stays atomic; verified fixing both bleve and OpenSearch.
This commit is contained in:
Dominik Schmidt committed 2026-08-31 13:40:42 +02:00
1 parent 69c517a8b2
commit c94059fb85
6 files changed
+33 -67

No files matched your search

+15 -2
View File
@@ -190,8 +190,8 @@ var _ = Describe("Bleve", func() {
parentResource.Document.Tags = []string{"Work", "Urgent"}
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
assertDocCount(rootResource.ID, "tag:work", 1) // stored "Work", queried lower
assertDocCount(rootResource.ID, "tag:WORK", 1) // queried upper
assertDocCount(rootResource.ID, "tag:work", 1) // stored "Work", queried lower
assertDocCount(rootResource.ID, "tag:WORK", 1) // queried upper
assertDocCount(rootResource.ID, "Tags:Urgent", 1)
assertDocCount(rootResource.ID, "tag:missing", 0)
})
@@ -397,6 +397,19 @@ var _ = Describe("Bleve", func() {
assertDocCount(rootResource.ID, "MimeType:image/svg+xml", 1)
assertDocCount(rootResource.ID, "MimeType:image/png", 1)
})
It("combines mediatype:file with another term", func() {
// regression: mediatype:file (a NOT) next to an operator dropped the
// other operand, so mediatype:file AND name:x matched nothing.
parentResource.Document.MimeType = "httpd/unix-directory" // a folder
childResource.Document.MimeType = "image/png" // a file
for _, r := range []search.Resource{parentResource, childResource} {
Expect(eng.Upsert(r.ID, r)).To(Succeed())
}
assertDocCount(rootResource.ID, "mediatype:file", 1) // only the file
assertDocCount(rootResource.ID, "mediatype:file AND name:child.pdf", 1) // file AND its name
assertDocCount(rootResource.ID, "mediatype:file AND name:nope", 0)
})
})
Context("Highlights", func() {
@@ -1,57 +0,0 @@
package convert_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/pkg/ast"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
)
// LowerValues runs after the shared query.Normalize pass, so it operates on
// already-resolved pointer nodes. Field resolution, media-type expansion and
// group-key defaulting are tested once at the query.Normalize level (see
// pkg/query normalize_test), not here. Only lowercase-analyzed fields get their
// value folded; case-preserved fields keep their casing.
func TestLowerValues(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "lowercase-analyzed field: value is folded, recursing into groups",
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{
&ast.StringNode{Key: "aBc", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "StringNode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "StringNode"},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
require.Equal(t, test.Want, convert.LowerValues(test.Got))
})
}
}
@@ -321,8 +321,8 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name_lowercase:john\ smith`),
query.NewQueryStringQuery(`Hidden:T`),
query.NewQueryStringQuery(`Hidden:T`),
query.NewQueryStringQuery(`Hidden:t`),
query.NewQueryStringQuery(`Hidden:t`),
}),
wantErr: false,
},
@@ -23,9 +23,14 @@ func Expand(key, value string) []ast.Node {
value = strings.ToLower(value)
switch value {
case "file":
// Group the negation so it stays atomic next to an operator: a bare
// `NOT <term>` sequence spliced inline miscompiles as the left of an AND
// (mediatype:file AND name:x would drop name:x).
return []ast.Node{
&ast.OperatorNode{Value: kql.BoolNOT},
&ast.StringNode{Key: field, Value: "httpd/unix-directory"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: kql.BoolNOT},
&ast.StringNode{Key: field, Value: "httpd/unix-directory"},
}},
}
case "folder":
return term("httpd/unix-directory")
@@ -45,9 +45,12 @@ func TestExpand_literalValuePassesThroughToMimeType(t *testing.T) {
}
func TestExpand_fileIsNotAFolder(t *testing.T) {
// grouped so the negation stays atomic next to an operator.
require.Equal(t, []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}},
}, mimetype.Expand("mediatype", "file"))
}
+4 -2
View File
@@ -57,8 +57,10 @@ func TestNormalize_ResolvesFieldsAndExpandsMediatype(t *testing.T) {
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "photo.cameraMake", Value: "canon"},
&ast.OperatorNode{Value: "AND"},
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.NumberNode{Key: "Size", Value: 100},
}, got)