fix(search): answer the same on both engines

both engines now agree on names, titles, tags, paths, types, sizes, dates,
hidden flags, facet values and wildcards. quotes only delimit phrases and
the equals operator matches the whole field value, following the kql spec.
the index name carries a generation so a changed mapping starts on a fresh
index, MIGRATION.md says how to fill it.
This commit is contained in:
Florian Schade committed 2026-08-31 10:44:03 +02:00
1 parent ffbfe171c3
commit bd4287762e
39 files changed
+2040 -743

No files matched your search

+9
View File
@@ -43,6 +43,7 @@ type StringNode struct {
*Base
Key string
Value string
Exact bool
}
// BooleanNode represents a bool value
@@ -60,6 +61,14 @@ type DateTimeNode struct {
Value time.Time
}
// NumberNode represents a numeric value
type NumberNode struct {
*Base
Key string
Operator *OperatorNode
Value float64
}
// OperatorNode represents an operator value like
// AND, OR, NOT, =, <= ... and so on
type OperatorNode struct {
+1
View File
@@ -21,6 +21,7 @@ func DiffAst(x, y any, opts ...cmp.Option) string {
cmpopts.IgnoreFields(ast.GroupNode{}, "Base"),
cmpopts.IgnoreFields(ast.BooleanNode{}, "Base"),
cmpopts.IgnoreFields(ast.DateTimeNode{}, "Base"),
cmpopts.IgnoreFields(ast.NumberNode{}, "Base"),
)...,
)
}
+10
View File
@@ -2,6 +2,7 @@ package kql
import (
"fmt"
"strconv"
"time"
"github.com/jinzhu/now"
@@ -136,3 +137,12 @@ func toTimeRange(in any) (*time.Time, *time.Time, error) {
return &from, &to, nil
}
func toFloat(v any) (float64, error) {
value, err := toString(v)
if err != nil {
return 0, err
}
return strconv.ParseFloat(value, 64)
}
+23 -4
View File
@@ -40,6 +40,7 @@ GroupNode <-
PropertyRestrictionNodes <-
YesNoPropertyRestrictionNode /
DateTimeRestrictionNode /
NumberRestrictionNode /
TextPropertyRestrictionNode
YesNoPropertyRestrictionNode <-
@@ -69,9 +70,22 @@ DateTimeRestrictionNode <-
return buildNaturalLanguageDateTimeNodes(k, v, c.text, c.pos)
}
NumberRestrictionNode <-
k:Key o:(
OperatorGreaterOrEqualNode /
OperatorLessOrEqualNode /
OperatorGreaterNode /
OperatorLessNode
) '"'? v:Number '"'? {
return buildNumberNode(k, o, v, c.text, c.pos)
}
TextPropertyRestrictionNode <-
k:Key (OperatorColonNode / OperatorEqualNode) v:(String / [^ ()]+){
return buildStringNode(k, v, c.text, c.pos)
k:Key OperatorEqualNode v:(String / [^ ()]+) {
return buildStringNode(k, v, true, c.text, c.pos)
} /
k:Key OperatorColonNode v:(String / [^ ()]+) {
return buildStringNode(k, v, false, c.text, c.pos)
}
////////////////////////////////////////////////////////
@@ -84,12 +98,12 @@ FreeTextKeywordNodes <-
PhraseNode <-
OperatorColonNode? _ v:String _ OperatorColonNode? {
return buildStringNode("", v, c.text, c.pos)
return buildStringNode("", v, false, c.text, c.pos)
}
WordNode <-
OperatorColonNode? _ v:[^ :()]+ _ OperatorColonNode? {
return buildStringNode("", v, c.text, c.pos)
return buildStringNode("", v, false, c.text, c.pos)
}
////////////////////////////////////////////////////////
@@ -229,6 +243,11 @@ String <-
return v, nil
}
Number <-
[0-9]+ ("." [0-9]+)? {
return string(c.text), nil
}
Digit <-
[0-9] {
return c.text, nil
+928 -483
View File
File diff suppressed because it is too large. Load diff
+70
View File
@@ -145,6 +145,22 @@ func TestParse_Spec(t *testing.T) {
},
},
},
{
name: `author="John Smith"`,
ast: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "author", Value: "John Smith", Exact: true},
},
},
},
{
name: `filename=budget.xlsx`,
ast: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "filename", Value: "budget.xlsx", Exact: true},
},
},
},
// 3.2.3 Implicit Operator for Property Restriction
{
name: `author:"John Smith" filetype:docx`,
@@ -423,6 +439,60 @@ func TestParse_Spec(t *testing.T) {
}
}
func TestParse_NumberRestrictionNode(t *testing.T) {
tests := []testCase{
{
name: "format",
query: join([]string{
`size>100`,
`size>"100"`,
`size>=15.5`,
`size<100`,
`size<=100`,
}),
ast: &ast.Ast{
Nodes: []ast.Node{
&ast.NumberNode{
Key: "size",
Operator: &ast.OperatorNode{Value: ">"},
Value: 100,
},
&ast.OperatorNode{Value: kql.BoolAND},
&ast.NumberNode{
Key: "size",
Operator: &ast.OperatorNode{Value: ">"},
Value: 100,
},
&ast.OperatorNode{Value: kql.BoolAND},
&ast.NumberNode{
Key: "size",
Operator: &ast.OperatorNode{Value: ">="},
Value: 15.5,
},
&ast.OperatorNode{Value: kql.BoolAND},
&ast.NumberNode{
Key: "size",
Operator: &ast.OperatorNode{Value: "<"},
Value: 100,
},
&ast.OperatorNode{Value: kql.BoolAND},
&ast.NumberNode{
Key: "size",
Operator: &ast.OperatorNode{Value: "<="},
Value: 100,
},
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
testKQL(t, tc)
})
}
}
func TestParse_DateTimeRestrictionNode(t *testing.T) {
tests := []testCase{
{
-11
View File
@@ -1,11 +0,0 @@
package kql
import (
"time"
)
// PatchTimeNow is here to patch the package time now func,
// which is used in the test suite
func PatchTimeNow(t func() time.Time) {
timeNow = t
}
+32 -1
View File
@@ -50,7 +50,7 @@ func buildAST(n any, text []byte, pos position) (*ast.Ast, error) {
return a, nil
}
func buildStringNode(k, v any, text []byte, pos position) (*ast.StringNode, error) {
func buildStringNode(k, v any, exact bool, text []byte, pos position) (*ast.StringNode, error) {
b, err := base(text, pos)
if err != nil {
return nil, err
@@ -70,6 +70,7 @@ func buildStringNode(k, v any, text []byte, pos position) (*ast.StringNode, erro
Base: b,
Key: key,
Value: value,
Exact: exact,
}, nil
}
@@ -101,6 +102,36 @@ func buildDateTimeNode(k, o, v any, text []byte, pos position) (*ast.DateTimeNod
Value: value,
}, nil
}
func buildNumberNode(k, o, v any, text []byte, pos position) (*ast.NumberNode, error) {
b, err := base(text, pos)
if err != nil {
return nil, err
}
operator, err := toNode[*ast.OperatorNode](o)
if err != nil {
return nil, err
}
key, err := toString(k)
if err != nil {
return nil, err
}
value, err := toFloat(v)
if err != nil {
return nil, err
}
return &ast.NumberNode{
Base: b,
Key: key,
Operator: operator,
Value: value,
}, nil
}
func buildNaturalLanguageDateTimeNodes(k, v any, text []byte, pos position) ([]ast.Node, error) {
b, err := base(text, pos)
if err != nil {
+6
View File
@@ -47,3 +47,9 @@ func (b Builder) Build(q string) (*ast.Ast, error) {
// timeNow mirrors time.Now by default, the only reason why this exists
// is to monkey patch it from the tests. See PatchTimeNow
var timeNow = time.Now
// PatchTimeNow pins the clock the natural language dates resolve against,
// so a test can hold "today" still while it runs
func PatchTimeNow(t func() time.Time) {
timeNow = t
}
+55
View File
@@ -0,0 +1,55 @@
# Migrating the search index
A version that changes how resources are indexed works on a new index and
leaves the old one untouched. The service starts normally, but the new index is
empty: search finds nothing until it is filled. The old index stays around
until you remove it.
## v7.x.x to %%NEXT%%
### OpenSearch
The new index is `opencloud-resource-v3`. Fill it in one of two ways:
- copy the old index, fast and keeps the extracted file contents, or
- index all spaces again, slower since every file is read once more, but drops
documents that no longer have a resource.
The address below is the one from `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ADDRESSES`,
`opencloud-resource` the name from
`SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME`.
```shell
# either copy the old index
curl -X POST "https://opensearch.example.com:9200/_reindex?wait_for_completion=false" \
-H 'Content-Type: application/json' -d '
{"source":{"index":"opencloud-resource"},"dest":{"index":"opencloud-resource-v3"}}'
# the answer carries a task id, watch it while it runs
curl "https://opensearch.example.com:9200/_tasks/<task-id>"
# or index all spaces again, the service keeps running while it happens
opencloud search index --all-spaces
```
Once the new index is filled, remove the old one:
```shell
curl -X DELETE "https://opensearch.example.com:9200/opencloud-resource"
```
### bleve
The new index is the `bleve-v2` directory next to the old `bleve` one, both in
`$OC_BASE_DATA_PATH/search` by default (`SEARCH_ENGINE_BLEVE_DATA_PATH`). A
bleve index cannot be copied, index all spaces again:
```shell
opencloud search index --all-spaces
```
Once the new index is filled, remove the old one:
```shell
rm -r "$OC_BASE_DATA_PATH/search/bleve"
```
+6
View File
@@ -55,6 +55,12 @@ Additionally, the following optional settings can be set:
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ENABLE_DEBUG_LOGGER=val`: Enable debug logging.
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_INSECURE=val`: Skip TLS certificate verification.
### Bringing an existing index over
A version that changes how resources are indexed works on a new index and leaves the old one untouched.
The new index starts empty and has to be filled, see [MIGRATION.md](MIGRATION.md). New installations are
not affected.
## Query language
By default, [KQL](https://learn.microsoft.com/en-us/sharepoint/dev/general-development/keyword-query-language-kql-syntax-reference) is used as the query language.
@@ -5,10 +5,7 @@ import (
"testing"
"time"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/pkg/conversions"
)
var TimeMustParse = func(t testing.TB, ts string) time.Time {
@@ -23,14 +20,3 @@ func JSONMustMarshal(t testing.TB, data any) string {
require.NoError(t, err, "failed to marshal data to JSON")
return string(jsonData)
}
func SearchHitsMustBeConverted[T any](t testing.TB, hits []opensearchgoAPI.SearchHit) []T {
ts := make([]T, len(hits))
for i, hit := range hits {
resource, err := conversions.To[T](hit.Source)
require.NoError(t, err)
ts[i] = resource
}
return ts
}
@@ -61,7 +61,6 @@ func (tc *TestClient) IndicesReset(ctx context.Context, indices []string) error
}
if len(indicesToDelete) == 0 {
// If no indices to delete, return nil
return nil
}
@@ -138,76 +137,6 @@ func (tc *TestClient) IndicesCreate(ctx context.Context, index string, body io.R
}
}
func (tc *TestClient) IndicesCount(ctx context.Context, indices []string, body io.Reader) (int, error) {
if err := tc.IndicesRefresh(ctx, indices, []int{404}); err != nil {
return 0, err
}
resp, err := tc.c.Indices.Count(ctx, &opensearchgoAPI.IndicesCountReq{
Indices: indices,
Body: body,
})
switch {
case err != nil:
return 0, fmt.Errorf("failed to count documents in indices: %w", err)
default:
return resp.Count, nil
}
}
func (tc *TestClient) DocumentCreate(ctx context.Context, index, id string, body io.Reader) error {
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
return err
}
_, err := tc.c.Document.Create(ctx, opensearchgoAPI.DocumentCreateReq{
Index: index,
DocumentID: id,
Body: body,
})
switch {
case err != nil:
return fmt.Errorf("failed to create document in index %s: %w", index, err)
default:
return nil
}
}
func (tc *TestClient) Update(ctx context.Context, index, id string, body io.Reader) error {
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
return err
}
_, err := tc.c.Update(ctx, opensearchgoAPI.UpdateReq{
Index: index,
DocumentID: id,
Body: body,
})
switch {
case err != nil:
return fmt.Errorf("failed to update document in index %s: %w", index, err)
default:
return nil
}
}
func (tc *TestClient) Search(ctx context.Context, index string, body io.Reader) (opensearchgoAPI.SearchHits, error) {
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
return opensearchgoAPI.SearchHits{}, err
}
resp, err := tc.c.Search(ctx, &opensearchgoAPI.SearchReq{
Indices: []string{index},
Body: body,
})
if err != nil {
return opensearchgoAPI.SearchHits{}, fmt.Errorf("failed to search in index %s: %w", index, err)
}
return resp.Hits, nil
}
type testRequireClient struct {
tc *TestClient
t testing.TB
@@ -228,29 +157,3 @@ func (trc *testRequireClient) IndicesCreate(index string, body io.Reader) {
func (trc *testRequireClient) IndicesDelete(indices []string) {
require.NoError(trc.t, trc.tc.IndicesDelete(trc.t.Context(), indices))
}
func (trc *testRequireClient) IndicesCount(indices []string, body io.Reader, expected int) {
count, err := trc.tc.IndicesCount(trc.t.Context(), indices, body)
switch {
case expected <= 0:
require.True(trc.t, count <= 0, "expected indices to have no documents, but got a count of %d", count)
default:
require.Equal(trc.t, expected, count, "expected indices to have %d documents, but got %d", expected, count)
require.NoError(trc.t, err, "expected indices to have documents, but got an error")
}
}
func (trc *testRequireClient) DocumentCreate(index, id string, body io.Reader) {
require.NoError(trc.t, trc.tc.DocumentCreate(trc.t.Context(), index, id, body))
}
func (trc *testRequireClient) Update(index, id string, body io.Reader) {
require.NoError(trc.t, trc.tc.Update(trc.t.Context(), index, id, body))
}
func (trc *testRequireClient) Search(index string, body io.Reader) opensearchgoAPI.SearchHits {
hits, err := trc.tc.Search(trc.t.Context(), index, body)
require.NoError(trc.t, err)
return hits
}
@@ -67,7 +67,6 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
keepContainer := os.Getenv("KEEP_TEST_CONTAINER") == "true"
if keepContainer {
// the reaper would take the kept container down with the session
if err := os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true"); err != nil {
return nil, fmt.Errorf("failed to disable the testcontainers reaper: %w", err)
}
@@ -83,14 +82,6 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
opensearch.WithPassword(cfg.Engine.OpenSearch.Client.Password),
testcontainers.WithName(containerName),
testcontainers.WithReuseByName(containerName),
// test indexes are tiny; don't let a full host disk trip the flood-stage
// create-index / read-only blocks mid-run
testcontainers.WithEnv(map[string]string{
"cluster.routing.allocation.disk.threshold_enabled": "false",
}),
// a health probe answers at once on a reused container, a log wait
// would sit out the log timeout on it; a cold boot takes well over the
// previous 5s
testcontainers.WithWaitStrategy(
wait.ForHTTP("/_cluster/health?wait_for_status=yellow&timeout=1s").
WithPort(openSearchPort).
@@ -106,16 +97,13 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
address, err := container.Address(ctx)
if err != nil {
_ = container.Terminate(ctx) // attempt to clean up the container
_ = container.Terminate(ctx)
return nil, fmt.Errorf("failed to get OpenSearch container address: %w", err)
}
// Ensure the address is set in the default configuration.
cfg.Engine.OpenSearch.Client.Addresses = []string{address}
return func() {
// KEEP_TEST_CONTAINER=true leaves the container up for the next run,
// which picks it up again by name instead of booting a fresh one
if keepContainer {
_, _ = fmt.Fprintf(os.Stderr, "keeping OpenSearch container %s\n", containerName)
return
@@ -16,16 +16,12 @@ var Testdata = struct {
Resources resourceTestdata
}{
Resources: resourceTestdata{
Root: fromTestData[search.Resource]("resource_root.json"),
Folder: fromTestData[search.Resource]("resource_folder.json"),
File: fromTestData[search.Resource]("resource_file.json"),
File: fromTestData[search.Resource]("resource_file.json"),
},
}
type resourceTestdata struct {
Root search.Resource
File search.Resource
Folder search.Resource
File search.Resource
}
func fromTestData[D any](name string) D {
+55 -16
View File
@@ -8,8 +8,8 @@ import (
"github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
regexpCharFilter "github.com/blevesearch/bleve/v2/analysis/char/regexp"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/token/porter"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/single"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/mapping"
@@ -18,10 +18,19 @@ import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
const (
wildcardSuffix = ".wildcard"
indexVersion = "v2"
)
func indexPath(root string) string {
return filepath.Join(root, "bleve-"+indexVersion)
}
func NewIndex(root string) (bleve.Index, error) {
destination := filepath.Join(root, "bleve")
destination := indexPath(root)
index, err := bleve.Open(destination)
if errors.Is(bleve.ErrorIndexPathDoesNotExist, err) {
if errors.Is(err, bleve.ErrorIndexPathDoesNotExist) {
indexMapping, err := NewMapping()
if err != nil {
return nil, err
@@ -38,30 +47,61 @@ func NewIndex(root string) (bleve.Index, error) {
}
func NewMapping() (mapping.IndexMapping, error) {
nameMapping := bleve.NewTextFieldMapping()
nameMapping.Analyzer = "lowercaseKeyword"
words := func() *mapping.FieldMapping {
fm := bleve.NewTextFieldMapping()
fm.Analyzer = "lowercaseWords"
return fm
}
whole := func(field string) *mapping.FieldMapping {
fm := bleve.NewTextFieldMapping()
fm.Analyzer = "lowercaseKeyword"
fm.IncludeInAll = false
fm.Name = field + wildcardSuffix
return fm
}
lowercaseMapping := bleve.NewTextFieldMapping()
lowercaseMapping.IncludeInAll = false
lowercaseMapping.Analyzer = "lowercaseKeyword"
fulltextFieldMapping := bleve.NewTextFieldMapping()
fulltextFieldMapping.Analyzer = "fulltext"
fulltextFieldMapping.IncludeInAll = false
contentMapping := words()
contentMapping.IncludeInAll = false
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("Name", nameMapping)
docMapping.AddFieldMappingsAt("Name",
words(),
whole("Name"),
)
docMapping.AddFieldMappingsAt("Title",
words(),
whole("Title"),
)
docMapping.AddFieldMappingsAt("Tags", lowercaseMapping)
docMapping.AddFieldMappingsAt("Favorites", lowercaseMapping)
docMapping.AddFieldMappingsAt("Content", fulltextFieldMapping)
docMapping.AddFieldMappingsAt("Content", contentMapping)
indexMapping := bleve.NewIndexMapping()
indexMapping.DefaultAnalyzer = keyword.Name
indexMapping.DefaultMapping = docMapping
err := indexMapping.AddCustomAnalyzer("lowercaseKeyword",
err := indexMapping.AddCustomCharFilter("dotToSpace",
map[string]any{
"type": custom.Name,
"tokenizer": single.Name,
"type": regexpCharFilter.Name,
"regexp": `\.`,
"replace": " ",
},
)
if err != nil {
return nil, err
}
err = indexMapping.AddCustomAnalyzer("lowercaseWords",
map[string]any{
"type": custom.Name,
"char_filters": []string{"dotToSpace"},
"tokenizer": unicode.Name,
"token_filters": []string{
lowercase.Name,
},
@@ -71,13 +111,12 @@ func NewMapping() (mapping.IndexMapping, error) {
return nil, err
}
err = indexMapping.AddCustomAnalyzer("fulltext",
err = indexMapping.AddCustomAnalyzer("lowercaseKeyword",
map[string]any{
"type": custom.Name,
"tokenizer": unicode.Name,
"tokenizer": single.Name,
"token_filters": []string{
lowercase.Name,
porter.Name,
},
},
)
+39
View File
@@ -0,0 +1,39 @@
package bleve_test
import (
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
)
var _ = Describe("Index", func() {
Describe("NewIndex", func() {
It("puts the index into a directory of its own generation", func() {
root := GinkgoT().TempDir()
index, err := bleve.NewIndex(root)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(index.Close)
Expect(index.Name()).To(Equal(filepath.Join(root, "bleve-v2")))
Expect(filepath.Join(root, "bleve-v2")).To(BeADirectory())
})
It("opens the index that is already there", func() {
root := GinkgoT().TempDir()
index, err := bleve.NewIndex(root)
Expect(err).ToNot(HaveOccurred())
Expect(index.Close()).To(Succeed())
reopened, err := bleve.NewIndex(root)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(reopened.Close)
Expect(reopened.Name()).To(Equal(filepath.Join(root, "bleve-v2")))
})
})
})
+1 -1
View File
@@ -18,7 +18,7 @@ type Document struct {
Name string
Content string
Size uint64
Mtime string
Mtime string `json:"Mtime,omitempty"`
MimeType string
Tags []string
Favorites []string
+6 -2
View File
@@ -11,8 +11,8 @@ import (
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/google/go-tika/tika"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
@@ -83,7 +83,11 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document,
}
for _, meta := range metas {
if title, err := getFirstValue(meta, "title"); err == nil {
title, err := getFirstValue(meta, "dc:title")
if err != nil {
title, err = getFirstValue(meta, "title")
}
if err == nil {
doc.Title = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Title, title))
}
+22
View File
@@ -91,6 +91,28 @@ var _ = Describe("Tika", func() {
Expect(doc.Content).To(Equal(body))
})
It("adds the title", func() {
fullResponse = `[{"dc:title": "quarterly report", "X-TIKA:content": "some data"}]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
Expect(doc.Title).To(Equal("quarterly report"))
})
It("adds the title of an older tika", func() {
fullResponse = `[{"title": "quarterly report"}]`
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Size: 1,
})
Expect(err).ToNot(HaveOccurred())
Expect(doc.Title).To(Equal("quarterly report"))
})
It("adds audio content", func() {
fullResponse = `[
{
+5 -2
View File
@@ -33,7 +33,10 @@ type Backend struct {
client *opensearchgoAPI.Client
}
func NewBackend(index string, client *opensearchgoAPI.Client) (*Backend, error) {
// NewBackend creates a backend on the versioned generation of the named index.
func NewBackend(name string, client *opensearchgoAPI.Client) (*Backend, error) {
index := IndexName(name)
pingResp, err := client.Ping(context.TODO(), &opensearchgoAPI.PingReq{})
switch {
case err != nil:
@@ -172,7 +175,7 @@ func (b *Backend) DocCount() (uint64, error) {
&opensearchgoAPI.IndicesCountReq{
Indices: []string{b.index},
},
osu.NewTermQuery[bool]("Deleted").Value(false),
osu.NewMatchAllQuery(),
)
if err != nil {
return 0, fmt.Errorf("failed to build count request: %w", err)
@@ -16,8 +16,6 @@ func TestOpenSearchBackend(t *testing.T) {
RunSpecs(t, "OpenSearch Backend Suite")
}
// what the engine does with its index is covered for both engines by
// services/search/pkg/parity; this is the one thing only OpenSearch can do
var _ = Describe("Backend", func() {
Describe("NewBackend", func() {
It("fails to create if the cluster is not healthy", func() {
+66 -21
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"path"
"reflect"
"strings"
"github.com/go-jose/go-jose/v3/json"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
@@ -16,9 +17,8 @@ import (
var (
ErrManualActionRequired = errors.New("manual action required")
IndexManagerLatest = IndexIndexManagerResourceV2
IndexIndexManagerResourceV1 IndexManager = "resource_v1.json"
IndexIndexManagerResourceV2 IndexManager = "resource_v2.json"
IndexManagerLatest = IndexIndexManagerResourceV3
IndexIndexManagerResourceV3 IndexManager = "resource_v3.json"
)
//go:embed internal/indexes/*.json
@@ -26,6 +26,30 @@ var indexes embed.FS
type IndexManager string
// Version is the part of the definition file name that says which generation it
// is, resource_v3.json carries v3.
func (m IndexManager) Version() string {
name := strings.TrimSuffix(string(m), path.Ext(string(m)))
_, version, found := strings.Cut(name, "_")
if !found {
return ""
}
return version
}
// IndexName puts the generation of the definition behind the configured name,
// so a new one starts on an index of its own instead of refusing to work with
// the one that is there.
func IndexName(name string) string {
version := IndexManagerLatest.Version()
if version == "" {
return name
}
return name + "-" + version
}
func (m IndexManager) String() string {
b, err := m.MarshalJSON()
if err != nil {
@@ -48,6 +72,43 @@ func (m IndexManager) MarshalJSON() ([]byte, error) {
return body, nil
}
func coveredAt(declared, index gjson.Result, declaredPath, indexPath string) (string, string, bool) {
declaredRaw := declared.Get(declaredPath).Raw
indexRaw := index.Get(indexPath).Raw
var declaredValue, indexValue any
if err := json.Unmarshal([]byte(declaredRaw), &declaredValue); err != nil {
return declaredRaw, indexRaw, false
}
if err := json.Unmarshal([]byte(indexRaw), &indexValue); err != nil {
return declaredRaw, indexRaw, false
}
return declaredRaw, indexRaw, covered(declaredValue, indexValue)
}
func covered(declared, index any) bool {
declaredMap, ok := declared.(map[string]any)
if !ok {
return reflect.DeepEqual(declared, index)
}
indexMap, ok := index.(map[string]any)
if !ok {
return false
}
for key, declaredValue := range declaredMap {
indexValue, ok := indexMap[key]
if !ok || !covered(declaredValue, indexValue) {
return false
}
}
return true
}
func (m IndexManager) Apply(ctx context.Context, name string, client *opensearchgoAPI.Client) error {
localIndexB, err := m.MarshalJSON()
if err != nil {
@@ -86,32 +147,16 @@ func (m IndexManager) Apply(ctx context.Context, name string, client *opensearch
localIndexJson := gjson.ParseBytes(localIndexB)
remoteIndexJson := gjson.ParseBytes(remoteIndexB)
compare := func(lvPath, rvPath string) (any, any, bool) {
lv := localIndexJson.Get(lvPath).Raw
rv := remoteIndexJson.Get(rvPath).Raw
var lvv, rvv any
if err := json.Unmarshal([]byte(lv), &lvv); err != nil {
return nil, nil, false
}
if err := json.Unmarshal([]byte(rv), &rvv); err != nil {
return nil, nil, false
}
return lv, rv, reflect.DeepEqual(lvv, rvv)
}
var errs []error
for k := range localIndexJson.Get("settings").Map() {
if lv, rv, ok := compare("settings."+k, "settings.index."+k); !ok {
if lv, rv, ok := coveredAt(localIndexJson, remoteIndexJson, "settings."+k, "settings.index."+k); !ok {
errs = append(errs, fmt.Errorf("settings.%s local %s, remote %s", k, lv, rv))
}
}
for k := range localIndexJson.Get("mappings.properties").Map() {
if _, _, ok := compare("mappings.properties."+k, "mappings.properties."+k); !ok {
if _, _, ok := coveredAt(localIndexJson, remoteIndexJson, "mappings.properties."+k, "mappings.properties."+k); !ok {
errs = append(errs, fmt.Errorf("mappings.properties.%s", k))
}
}
@@ -47,6 +47,34 @@ func TestIndexManager(t *testing.T) {
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client()))
})
t.Run("accepts an index that carries more than the definition declares", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Set(indexManager.String(), "mappings.properties.Path.fields.raw.type", "keyword")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client()))
})
t.Run("fails when the index misses something the definition declares", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{indexName})
body, err := sjson.Delete(indexManager.String(), "mappings.properties.Path.analyzer")
require.NoError(t, err)
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client()), opensearch.ErrManualActionRequired)
})
t.Run("fails to create index if it already exists but is not up to date", func(t *testing.T) {
indexManager := opensearch.IndexManagerLatest
indexName := "opencloud-test-resource"
@@ -4,8 +4,11 @@ import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/opencloud/pkg/ast"
)
@@ -53,6 +56,8 @@ func (e kqlExpander) expand(nodes []ast.Node, defaultKey string) ([]ast.Node, er
cnode.Key = e.remapKey(cnode.Key, defaultKey)
case *ast.BooleanNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
case *ast.NumberNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
}
if unfoldedNodes != nil {
@@ -73,6 +78,7 @@ func (_ kqlExpander) remapKey(current string, defaultKey string) string {
key, ok := map[string]string{
"": defaultKey, // Default case if current is empty
"title": "Title",
"rootid": "RootID",
"path": "Path",
"id": "ID",
@@ -85,7 +91,8 @@ func (_ kqlExpander) remapKey(current string, defaultKey string) string {
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
}[current]
"favorite": "Favorites",
}[strings.ToLower(current)]
if !ok {
return current // Return the original key if not found
}
@@ -94,15 +101,21 @@ 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
if slices.Contains([]string{"Name", "Title", "Tags", "Content", "MimeType", "Type", "Hidden"}, key) {
return strings.ToLower(value)
}
return strings.ToLower(value)
return value
}
func (_ kqlExpander) unfoldValue(key, value string) []ast.Node {
result, ok := map[string][]ast.Node{
"Type:file": {
&ast.StringNode{Key: key, Value: strconv.FormatUint(uint64(provider.ResourceType_RESOURCE_TYPE_FILE), 10)},
},
"Type:folder": {
&ast.StringNode{Key: key, Value: strconv.FormatUint(uint64(provider.ResourceType_RESOURCE_TYPE_CONTAINER), 10)},
},
"MimeType:file": {
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: key, Value: "httpd/unix-directory"},
@@ -139,8 +152,6 @@ func (_ kqlExpander) unfoldValue(key, value string) []ast.Node {
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.spreadshee"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.numbers"},
}},
},
@@ -142,6 +142,7 @@ func TestExpandKQLAST(t *testing.T) {
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
"favorite": "Favorites",
"any": "any", // Example of an unknown key that should remain unchanged
} {
tests = append(tests, opensearchtest.TableTest[[]ast.Node, []ast.Node]{
@@ -244,35 +245,72 @@ 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: "Name: StringNode -> stringnode",
Got: []ast.Node{
ast.StringNode{Key: "aBc", Value: "StringNode"},
ast.StringNode{Key: "Name", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "aBc", Value: "StringNode"},
ast.StringNode{Key: "Name", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
&ast.StringNode{Key: "Name", Value: "stringnode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "stringnode"},
&ast.StringNode{Key: "Name", Value: "stringnode"},
}},
},
},
{
Name: "Hidden: StringNode -> StringNode",
Name: "aBc: StringNode -> StringNode",
Got: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
ast.StringNode{Key: "aBc", Value: "StringNode"},
},
Want: []ast.Node{
&ast.StringNode{Key: "aBc", Value: "StringNode"},
},
},
{
Name: "Path: ./Documents -> ./Documents",
Got: []ast.Node{
ast.StringNode{Key: "Path", Value: "./Documents"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "StringNode"},
ast.StringNode{Key: "Path", Value: "./Documents"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
&ast.StringNode{Key: "Path", Value: "./Documents"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
&ast.StringNode{Key: "Path", Value: "./Documents"},
}},
},
},
{
Name: "Hidden: TRUE -> true",
Got: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "TRUE"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "Hidden", Value: "TRUE"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "true"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "true"},
}},
},
},
{
Name: "ID: 1$1!AB23 -> 1$1!AB23",
Got: []ast.Node{
ast.StringNode{Key: "ID", Value: "1$1!AB23"},
ast.StringNode{Key: "RootID", Value: "1$1!AB23"},
ast.StringNode{Key: "ParentID", Value: "1$1!AB23"},
},
Want: []ast.Node{
&ast.StringNode{Key: "ID", Value: "1$1!AB23"},
&ast.StringNode{Key: "RootID", Value: "1$1!AB23"},
&ast.StringNode{Key: "ParentID", Value: "1$1!AB23"},
},
},
}
for _, test := range tests {
@@ -384,8 +422,6 @@ func TestExpandKQLAST(t *testing.T) {
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.spreadshee"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.numbers"},
}},
&ast.OperatorNode{Value: "AND"},
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
@@ -101,6 +102,8 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
return stringNodeQuery(node), nil
case *ast.DateTimeNode:
return dateTimeNodeQuery(node)
case *ast.NumberNode:
return numberNodeQuery(node)
case *ast.GroupNode:
group, err := t.transpile(node.Nodes)
if err != nil {
@@ -115,22 +118,35 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
// stringNodeQuery picks the query a string node turns into.
func stringNodeQuery(node *ast.StringNode) osu.Builder {
isWildcard := strings.Contains(node.Value, "*")
isWildcard := strings.ContainsAny(node.Value, "*?")
switch {
// Name: "*oo-bar", "*oo ba*", "*OO*"
// Title: "*rterly rep*"
// Tags: "*spaced tag*"
case isWildcard && slices.Contains([]string{"Name", "Title", "Tags"}, node.Key):
return osu.NewWildcardQuery(node.Key + ".keyword").
Value(node.Value).
Params(&osu.WildcardQueryParams{CaseInsensitive: true})
case isWildcard && slices.Contains([]string{"Name", "Title"}, node.Key):
patterns := []osu.Builder{wildcardOn(node.Key+".wildcard", node.Value)}
if !strings.HasSuffix(node.Value, "*") {
patterns = append(patterns, wildcardOn(node.Key+".wildcard", node.Value+".*"))
}
return osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(patterns...)
// Tags: "*foo*", "*paced ta*"
case isWildcard && node.Key == "Tags":
return wildcardOn(node.Key+".wildcard", node.Value)
// Path: "./foo*", MimeType: "*plain"
case isWildcard:
return osu.NewWildcardQuery(node.Key).Value(node.Value)
// Name: =new, Title: ="quarterly report"
case node.Exact && slices.Contains([]string{"Name", "Title"}, node.Key):
return osu.NewTermQuery[string](node.Key + ".wildcard").
Value(node.Value).
Params(&osu.TermQueryParams{CaseInsensitive: true})
// Tags: "foo-bar", "spaced tag", "FOO-BAR"
case node.Key == "Tags":
return osu.NewTermQuery[string](node.Key + ".keyword").
return osu.NewTermQuery[string](node.Key + ".wildcard").
Value(node.Value).
Params(&osu.TermQueryParams{CaseInsensitive: true})
// Name: "foo-bar", "foo bar"
@@ -138,12 +154,62 @@ func stringNodeQuery(node *ast.StringNode) osu.Builder {
// Content: "foo bar"
case slices.Contains([]string{"Name", "Title", "Content"}, node.Key):
return osu.NewMatchPhraseQuery(node.Key).Query(node.Value)
// Path: "./foo bar", MimeType: "text/plain"
// Size: "42", Type: "1"
case slices.Contains([]string{"Size", "Type"}, node.Key):
number, err := strconv.ParseInt(node.Value, 10, 64)
if err != nil {
return osu.NewMatchNoneQuery()
}
return osu.NewTermQuery[int64](node.Key).Value(number)
// Path: "./foo bar/", the hierarchy tokens carry no trailing slash
case node.Key == "Path":
return osu.NewTermQuery[string](node.Key).Value(strings.TrimSuffix(node.Value, "/"))
// Hidden: "TRUE" arrives lowered, anything that is no bool matches nothing
case node.Key == "Hidden":
value, err := strconv.ParseBool(node.Value)
if err != nil {
return osu.NewMatchNoneQuery()
}
return osu.NewTermQuery[bool](node.Key).Value(value)
// MimeType: "text/plain"
default:
return osu.NewTermQuery[string](node.Key).Value(node.Value)
}
}
func numberNodeQuery(node *ast.NumberNode) (osu.Builder, error) {
if node.Operator == nil {
return nil, fmt.Errorf("number node without operator: %w", ErrUnsupportedNodeType)
}
if !slices.Contains([]string{"Size", "Type"}, node.Key) {
return osu.NewMatchNoneQuery(), nil
}
query := osu.NewRangeQuery[float64](node.Key)
switch node.Operator.Value {
case ">":
return query.Gt(node.Value), nil
case ">=":
return query.Gte(node.Value), nil
case "<":
return query.Lt(node.Value), nil
case "<=":
return query.Lte(node.Value), nil
}
return nil, fmt.Errorf("unsupported operator %s for number node: %w", node.Operator.Value, ErrUnsupportedNodeType)
}
func wildcardOn(field, value string) osu.Builder {
return osu.NewWildcardQuery(field).
Value(value).
Params(&osu.WildcardQueryParams{CaseInsensitive: true})
}
// dateTimeNodeQuery turns a date time node into a range query.
func dateTimeNodeQuery(node *ast.DateTimeNode) (osu.Builder, error) {
if node.Operator == nil {
@@ -58,12 +58,16 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
&ast.StringNode{Key: "Name", Value: "open*"},
},
},
Want: osu.NewWildcardQuery("Name.keyword").
Value("open*").
Params(&osu.WildcardQueryParams{CaseInsensitive: true}),
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewWildcardQuery("Name.wildcard").
Value("open*").
Params(&osu.WildcardQueryParams{CaseInsensitive: true}),
),
},
{
Name: "wildcard query - string node without a keyword sub field",
Name: "wildcard query - string node without an unanalyzed sub field",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Content", Value: "open*"},
@@ -71,6 +75,62 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
},
Want: osu.NewWildcardQuery("Content").Value("open*"),
},
{
Name: "wildcard query - a question mark counts as a wildcard",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "fo?o"},
},
},
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewWildcardQuery("Name.wildcard").
Value("fo?o").
Params(&osu.WildcardQueryParams{CaseInsensitive: true}),
osu.NewWildcardQuery("Name.wildcard").
Value("fo?o.*").
Params(&osu.WildcardQueryParams{CaseInsensitive: true}),
),
},
{
Name: "term query - an equals restriction matches the whole name",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "foo bar.txt", Exact: true},
},
},
Want: osu.NewTermQuery[string]("Name.wildcard").
Value("foo bar.txt").
Params(&osu.TermQueryParams{CaseInsensitive: true}),
},
{
Name: "term query - a path loses its trailing slash",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Path", Value: "./Documents/"},
},
},
Want: osu.NewTermQuery[string]("Path").Value("./Documents"),
},
{
Name: "term query - a hidden string turns into a bool",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "true"},
},
},
Want: osu.NewTermQuery[bool]("Hidden").Value(true),
},
{
Name: "match-none query - a hidden string that is no bool",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Hidden", Value: "banana"},
},
},
Want: osu.NewMatchNoneQuery(),
},
{
Name: "bool query",
Got: &ast.Ast{
@@ -10,7 +10,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -0,0 +1,122 @@
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": {
"analyzer": {
"path_hierarchy": {
"tokenizer": "path_hierarchy",
"type": "custom"
},
"name_words": {
"type": "custom",
"char_filter": [
"dot_to_space"
],
"tokenizer": "standard",
"filter": [
"lowercase"
]
}
},
"tokenizer": {
"path_hierarchy": {
"type": "path_hierarchy"
}
},
"normalizer": {
"lowercase": {
"type": "custom",
"filter": [
"lowercase"
]
}
},
"char_filter": {
"dot_to_space": {
"type": "pattern_replace",
"pattern": "\\.",
"replacement": " "
}
}
}
},
"mappings": {
"properties": {
"Content": {
"type": "text",
"analyzer": "name_words",
"term_vector": "with_positions_offsets"
},
"ID": {
"type": "keyword"
},
"ParentID": {
"type": "keyword"
},
"RootID": {
"type": "keyword"
},
"MimeType": {
"type": "wildcard"
},
"Path": {
"type": "text",
"analyzer": "path_hierarchy"
},
"Deleted": {
"type": "boolean"
},
"Hidden": {
"type": "boolean"
},
"Favorites": {
"type": "keyword"
},
"Tags": {
"type": "text",
"fields": {
"wildcard": {
"type": "wildcard",
"normalizer": "lowercase"
}
}
},
"Name": {
"type": "text",
"analyzer": "name_words",
"fields": {
"wildcard": {
"type": "wildcard",
"normalizer": "lowercase"
}
}
},
"Title": {
"type": "text",
"analyzer": "name_words",
"fields": {
"wildcard": {
"type": "wildcard",
"normalizer": "lowercase"
}
}
},
"Mtime": {
"type": "date",
"ignore_malformed": true
}
},
"dynamic_templates": [
{
"audio_facets": {
"path_match": "audio.*",
"match_mapping_type": "string",
"mapping": {
"type": "keyword"
}
}
}
]
}
}
@@ -0,0 +1,55 @@
package osu
import (
"encoding/json"
)
// MatchNoneQuery matches no document.
type MatchNoneQuery struct{}
// NewMatchNoneQuery creates a query that matches no document.
func NewMatchNoneQuery() *MatchNoneQuery {
return &MatchNoneQuery{}
}
// Map returns the query as a map.
func (q *MatchNoneQuery) Map() (map[string]any, error) {
return map[string]any{
"match_none": map[string]any{},
}, nil
}
// MarshalJSON returns the query as JSON.
func (q *MatchNoneQuery) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
// MatchAllQuery matches every document.
type MatchAllQuery struct{}
// NewMatchAllQuery creates a query that matches every document.
func NewMatchAllQuery() *MatchAllQuery {
return &MatchAllQuery{}
}
// Map returns the query as a map.
func (q *MatchAllQuery) Map() (map[string]any, error) {
return map[string]any{
"match_all": map[string]any{},
}, nil
}
// MarshalJSON returns the query as JSON.
func (q *MatchAllQuery) MarshalJSON() ([]byte, error) {
data, err := q.Map()
if err != nil {
return nil, err
}
return json.Marshal(data)
}
@@ -6,7 +6,8 @@ import (
"time"
)
type RangeQuery[T time.Time | string] struct {
// RangeQuery matches documents whose field value lies inside the bounds.
type RangeQuery[T time.Time | string | float64] struct {
field string
gt T
gte T
@@ -22,7 +23,8 @@ type RangeQueryParams struct {
TimeZone string `json:"time_zone,omitempty"`
}
func NewRangeQuery[T time.Time | string](field string) *RangeQuery[T] {
// NewRangeQuery creates a range query for the given field.
func NewRangeQuery[T time.Time | string | float64](field string) *RangeQuery[T] {
return &RangeQuery[T]{field: field}
}
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
)
@@ -6,7 +6,7 @@ import (
"os"
"testing"
opensearchtest "github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
)
+118 -9
View File
@@ -2,23 +2,26 @@ package bleve
import (
"fmt"
"slices"
"strconv"
"strings"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/blevesearch/bleve/v2"
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
)
// lowercaseFields lists the bleve fields whose index mapping uses a
// lowercasing analyzer. Values bound to these fields are pre-lowercased
// so query-side matching stays consistent with the index.
// Keep in sync with services/search/pkg/bleve/index.go NewMapping.
var lowercaseFields = map[string]struct{}{
"Name": {},
"Title": {},
"Tags": {},
"Favorites": {},
"Content": {},
"MimeType": {},
"Hidden": {},
}
var _fields = map[string]string{
@@ -33,6 +36,7 @@ var _fields = map[string]string{
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"title": "Title",
"hidden": "Hidden",
"favorite": "Favorites",
}
@@ -53,7 +57,6 @@ var bleveEscaper = strings.NewReplacer(
`)`, `\)`,
`{`, `\{`,
`}`, `\}`,
`{`, `\}`,
`[`, `\[`,
`]`, `\]`,
`^`, `\^`,
@@ -106,14 +109,43 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
v = strings.ToLower(v)
}
if k == "Type" {
v = resourceType(v)
}
var q bleveQuery.Query
var group bool
switch k {
case "MimeType":
switch {
case k == "Hidden":
value, err := strconv.ParseBool(v)
if err != nil {
q = bleveQuery.NewMatchNoneQuery()
break
}
bq := bleveQuery.NewBoolFieldQuery(value)
bq.SetField(k)
q = bq
case k == "MimeType":
q, group = mimeType(k, v)
if prev == nil {
isGroup = group
}
case slices.Contains([]string{"Name", "Title"}, k) && strings.ContainsAny(n.Value, "*?"):
patterns := []bleveQuery.Query{bleveQuery.NewQueryStringQuery(k + ".wildcard:" + v)}
if !strings.HasSuffix(v, "*") {
patterns = append(patterns, bleveQuery.NewQueryStringQuery(k+".wildcard:"+v+".*"))
}
q = closed(bleveQuery.NewDisjunctionQuery(patterns))
case n.Exact && !strings.ContainsAny(n.Value, "*?") && slices.Contains([]string{"Name", "Title"}, k):
q = bleveQuery.NewQueryStringQuery(k + ".wildcard:" + v)
case k == "Path" && !strings.ContainsAny(n.Value, "*?"):
q = pathAndBelow(k, n.Value)
case slices.Contains([]string{"Name", "Title", "Content"}, k) && !strings.ContainsAny(n.Value, "*?"):
q = phrase(k, n.Value)
case strings.Contains(n.Value, " ") && !strings.ContainsAny(n.Value, "*?"):
q = phrase(k, n.Value)
default:
q = bleveQuery.NewQueryStringQuery(k + ":" + v)
}
@@ -153,13 +185,25 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
continue
}
if prev == nil {
prev = q
} else {
next = q
}
case *ast.NumberNode:
q := numberRange(getField(n.Key), n.Operator, n.Value)
if q == nil {
continue
}
if prev == nil {
prev = q
} else {
next = q
}
case *ast.BooleanNode:
q := bleveQuery.NewQueryStringQuery(getField(n.Key) + fmt.Sprintf(":%v", n.Value))
q := bleveQuery.NewBoolFieldQuery(n.Value)
q.SetField(getField(n.Key))
if prev == nil {
prev = q
} else {
@@ -216,6 +260,10 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
func nextNode(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
if n, ok := nodes[offset].(*ast.GroupNode); ok {
if n.Key != "" {
n = normalizeGroupingProperty(n)
}
gq, _, err := walk(0, n.Nodes)
if err != nil {
return nil, 0, err
@@ -277,6 +325,57 @@ func mapBinary(operator *ast.OperatorNode, ln, rn bleveQuery.Query, leftIsGroup
})
}
func numberRange(field string, operator *ast.OperatorNode, value float64) bleveQuery.Query {
if operator == nil {
return nil
}
inclusive, exclusive := true, false
var q *bleveQuery.NumericRangeQuery
switch operator.Value {
case ">":
q = bleveQuery.NewNumericRangeInclusiveQuery(&value, nil, &exclusive, nil)
case ">=":
q = bleveQuery.NewNumericRangeInclusiveQuery(&value, nil, &inclusive, nil)
case "<":
q = bleveQuery.NewNumericRangeInclusiveQuery(nil, &value, nil, &exclusive)
case "<=":
q = bleveQuery.NewNumericRangeInclusiveQuery(nil, &value, nil, &inclusive)
default:
return nil
}
q.SetField(field)
return q
}
func pathAndBelow(field, path string) bleveQuery.Query {
path = strings.TrimSuffix(path, "/")
self := bleveQuery.NewTermQuery(path)
self.SetField(field)
below := bleveQuery.NewPrefixQuery(path + "/")
below.SetField(field)
return closed(bleveQuery.NewDisjunctionQuery([]bleveQuery.Query{self, below}))
}
func closed(q bleveQuery.Query) bleveQuery.Query {
// a bare disjunction reads as an open OR chain to mapBinary, a later OR
// would merge into it and widen the group
return bleveQuery.NewConjunctionQuery([]bleveQuery.Query{q})
}
func phrase(field, value string) bleveQuery.Query {
q := bleveQuery.NewMatchPhraseQuery(value)
q.SetField(field)
return q
}
func getField(name string) string {
if name == "" {
return "Name"
@@ -296,6 +395,17 @@ func normalizeGroupingProperty(group *ast.GroupNode) *ast.GroupNode {
return group
}
func resourceType(value string) string {
switch strings.ToLower(value) {
case "file":
return strconv.FormatUint(uint64(provider.ResourceType_RESOURCE_TYPE_FILE), 10)
case "folder":
return strconv.FormatUint(uint64(provider.ResourceType_RESOURCE_TYPE_CONTAINER), 10)
default:
return value
}
}
func mimeType(k, v string) (bleveQuery.Query, bool) {
switch v {
case "file":
@@ -321,7 +431,6 @@ func mimeType(k, v string) (bleveQuery.Query, bool) {
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.apple.numbers",
)), true
case "presentation":
@@ -1,6 +1,7 @@
package bleve
import (
"strings"
"testing"
"time"
@@ -18,6 +19,29 @@ var timeMustParse = func(t *testing.T, ts string) time.Time {
return tp
}
func wildcardQuery(field, value string) query.Query {
patterns := []query.Query{query.NewQueryStringQuery(field + ".wildcard:" + value)}
if !strings.HasSuffix(value, "*") {
patterns = append(patterns, query.NewQueryStringQuery(field+".wildcard:"+value+".*"))
}
return query.NewConjunctionQuery([]query.Query{query.NewDisjunctionQuery(patterns)})
}
func phraseQuery(field, value string) query.Query {
q := query.NewMatchPhraseQuery(value)
q.SetField(field)
return q
}
func boolFieldQuery(field string, value bool) query.Query {
q := query.NewBoolFieldQuery(value)
q.SetField(field)
return q
}
func Test_compile(t *testing.T) {
tests := []struct {
name string
@@ -33,7 +57,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:federated`),
phraseQuery("Name", "federated"),
}),
wantErr: false,
},
@@ -45,7 +69,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
phraseQuery("Name", "John Smith"),
}),
wantErr: false,
},
@@ -59,8 +83,8 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
query.NewQueryStringQuery(`Name:jane`),
phraseQuery("Name", "John Smith"),
phraseQuery("Name", "Jane"),
}),
wantErr: false,
},
@@ -91,7 +115,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
wildcardQuery("Name", `moby\ di*`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags:book`),
@@ -112,10 +136,10 @@ func Test_compile(t *testing.T) {
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewQueryStringQuery(`Name:b`),
phraseQuery("Name", "a"),
phraseQuery("Name", "b"),
}),
query.NewQueryStringQuery(`Name:c`),
phraseQuery("Name", "c"),
}),
wantErr: false,
},
@@ -131,10 +155,10 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
phraseQuery("Name", "a"),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:b`),
query.NewQueryStringQuery(`Name:c`),
phraseQuery("Name", "b"),
phraseQuery("Name", "c"),
}),
}),
wantErr: false,
@@ -156,11 +180,11 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:a`),
query.NewQueryStringQuery(`Name:b`),
query.NewQueryStringQuery(`Name:c`),
phraseQuery("Name", "a"),
phraseQuery("Name", "b"),
phraseQuery("Name", "c"),
}),
query.NewQueryStringQuery(`Name:d`),
phraseQuery("Name", "d"),
}),
wantErr: false,
},
@@ -179,7 +203,7 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
wildcardQuery("Name", `moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
@@ -204,7 +228,7 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:moby\ di*`),
wildcardQuery("Name", `moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
@@ -227,7 +251,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`author:John\ Smith`),
phraseQuery("author", "John Smith"),
query.NewQueryStringQuery(`author:Jane`),
}),
wantErr: false,
@@ -249,7 +273,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`author:John\ Smith`),
phraseQuery("author", "John Smith"),
query.NewQueryStringQuery(`author:Jane`),
query.NewQueryStringQuery(`Tags:bestseller`),
}),
@@ -293,9 +317,44 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith`),
query.NewQueryStringQuery(`Hidden:T`),
query.NewQueryStringQuery(`Hidden:T`),
phraseQuery("Name", "John Smith"),
boolFieldQuery("Hidden", true),
boolFieldQuery("Hidden", true),
}),
wantErr: false,
},
{
name: `hidden:banana`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "hidden", Value: "banana"},
},
},
want: query.NewConjunctionQuery([]query.Query{query.NewMatchNoneQuery()}),
wantErr: false,
},
{
name: `name="Report.txt"`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "name", Value: "Report.txt", Exact: true},
},
},
want: query.NewConjunctionQuery([]query.Query{query.NewQueryStringQuery(`Name.wildcard:report.txt`)}),
wantErr: false,
},
{
name: `type:File`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "type", Value: "File"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "type", Value: "FOLDER"},
},
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Type:1`),
query.NewQueryStringQuery(`Type:2`),
}),
wantErr: false,
},
@@ -424,7 +483,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
wildcardQuery("Name", `*tdd*`),
}),
wantErr: false,
},
@@ -450,7 +509,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/pdf`),
query.NewQueryStringQuery(`Name:*tdd*`),
wildcardQuery("Name", `*tdd*`),
}),
}),
wantErr: false,
@@ -480,7 +539,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewQueryStringQuery(`MimeType:application/pdf`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
wildcardQuery("Name", `*tdd*`),
}),
wantErr: false,
},
@@ -509,7 +568,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
query.NewQueryStringQuery(`Name:*tdd*`),
wildcardQuery("Name", `*tdd*`),
}),
wantErr: false,
},
@@ -521,7 +580,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Name:john\ smith\ \+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\ `),
phraseQuery("Name", "John Smith +-=&|><!(){}[]^\"~: "),
}),
wantErr: false,
},
@@ -0,0 +1,19 @@
@tikaServiceNeeded
Feature: title search
As a user
I want to find a document by the title in its metadata
So that I find it even when its name says nothing about it
Background:
Given user "Alice" has been created with default attributes
And using spaces DAV path
Scenario: search a document by the title the extractor read from it
Given user "Alice" has uploaded file with content "<html><head><title>quarterly report</title></head><body>some data</body></html>" to "q1.html"
And user "Alice" has uploaded file with content "<html><head><title>notes</title></head><body>some data</body></html>" to "q2.html"
When user "Alice" searches for 'Title:"quarterly report"' using the WebDAV API
Then the HTTP status code should be "207"
And the search result of user "Alice" should contain only these entries:
| q1.html |
+65
View File
@@ -0,0 +1,65 @@
// Copyright (c) 2014 Couchbase, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package regexp
import (
"fmt"
"regexp"
"github.com/blevesearch/bleve/v2/analysis"
"github.com/blevesearch/bleve/v2/registry"
)
const Name = "regexp"
type CharFilter struct {
r *regexp.Regexp
replacement []byte
}
func New(r *regexp.Regexp, replacement []byte) *CharFilter {
return &CharFilter{
r: r,
replacement: replacement,
}
}
func (s *CharFilter) Filter(input []byte) []byte {
return s.r.ReplaceAll(input, s.replacement)
}
func CharFilterConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.CharFilter, error) {
regexpStr, ok := config["regexp"].(string)
if !ok {
return nil, fmt.Errorf("must specify regexp")
}
r, err := regexp.Compile(regexpStr)
if err != nil {
return nil, fmt.Errorf("unable to build regexp char filter: %v", err)
}
replaceBytes := []byte(" ")
replaceStr, ok := config["replace"].(string)
if ok {
replaceBytes = []byte(replaceStr)
}
return New(r, replaceBytes), nil
}
func init() {
err := registry.RegisterCharFilter(Name, CharFilterConstructor)
if err != nil {
panic(err)
}
}
+1
View File
@@ -126,6 +126,7 @@ github.com/blevesearch/bleve/v2/analysis/analyzer/custom
github.com/blevesearch/bleve/v2/analysis/analyzer/keyword
github.com/blevesearch/bleve/v2/analysis/analyzer/standard
github.com/blevesearch/bleve/v2/analysis/datetime/flexible
github.com/blevesearch/bleve/v2/analysis/char/regexp
github.com/blevesearch/bleve/v2/analysis/datetime/optional
github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/microseconds
github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/milliseconds