mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-10-26 08:09:30 -04:00
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:
1 parent
ffbfe171c3
commit
bd4287762e
39 files changed
+2040
-743
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `[
|
||||
{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user