Merge pull request #3345 from opencloud-eu/refactor/search-mapping

refactor: reflection-based search mapping + location geopoint
This commit is contained in:
Dominik Schmidt authored and GitHub committed 2026-08-31 15:12:49 +02:00
commit d38fbc8e52
84 files changed
+3922 -2096

No files matched your search

+3
View File
@@ -44,6 +44,9 @@ type StringNode struct {
Key string
Value string
Exact bool
// CaseInsensitive marks a case-insensitive restriction; set by the search
// lowering pass, a backend routes it to the field's lowercased form.
CaseInsensitive bool
}
// BooleanNode represents a bool value
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"github.com/jinzhu/now"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
func toNode[T ast.Node](in any) (T, error) {
@@ -85,7 +84,7 @@ func toTimeRange(in any) (*time.Time, *time.Time, error) {
value, err := toString(in)
if err != nil {
return &from, &to, &query.UnsupportedTimeRangeError{}
return &from, &to, &UnsupportedTimeRangeError{}
}
c := &now.Config{
@@ -132,7 +131,7 @@ func toTimeRange(in any) (*time.Time, *time.Time, error) {
}
if from.IsZero() || to.IsZero() {
return nil, nil, &query.UnsupportedTimeRangeError{}
return nil, nil, &UnsupportedTimeRangeError{}
}
return &from, &to, nil
+10 -11
View File
@@ -9,7 +9,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/ast/test"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
tAssert "github.com/stretchr/testify/assert"
)
@@ -34,13 +33,13 @@ func TestParse_Spec(t *testing.T) {
},
{
name: `AND`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
{
name: `AND cat AND dog`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
@@ -80,13 +79,13 @@ func TestParse_Spec(t *testing.T) {
},
{
name: `OR`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
{
name: `OR cat AND dog`,
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
@@ -930,37 +929,37 @@ func TestParse_Errors(t *testing.T) {
tests := []testCase{
{
query: "animal:(mammal:cat mammal:dog reptile:turtle)",
error: query.NamedGroupInvalidNodesError{
error: kql.NamedGroupInvalidNodesError{
Node: &ast.StringNode{Key: "mammal", Value: "cat"},
},
},
{
query: "animal:(cat mammal:dog turtle)",
error: query.NamedGroupInvalidNodesError{
error: kql.NamedGroupInvalidNodesError{
Node: &ast.StringNode{Key: "mammal", Value: "dog"},
},
},
{
query: "animal:(AND cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
{
query: "animal:(OR cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
{
query: "(AND cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
{
query: "(OR cat)",
error: query.StartsWithBinaryOperatorError{
error: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolOR},
},
},
@@ -1,4 +1,4 @@
package query
package kql
import (
"errors"
@@ -39,8 +39,9 @@ func (e UnsupportedTimeRangeError) Error() string {
return fmt.Sprintf("unable to convert '%v' to a time range", e.Value)
}
// IsValidationError says whether the query itself is at fault, which makes it a
// bad request and not an error of ours.
// IsValidationError reports whether err is one of the KQL parse/validation
// errors produced by this package, i.e. the query itself is at fault and the
// caller should treat it as a bad request.
func IsValidationError(err error) bool {
var (
startsWithBinaryOperator *StartsWithBinaryOperatorError
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
tAssert "github.com/stretchr/testify/assert"
)
@@ -22,7 +21,7 @@ func TestNewAST(t *testing.T) {
{
name: "error",
givenQuery: kql.BoolAND,
expectedError: query.StartsWithBinaryOperatorError{
expectedError: kql.StartsWithBinaryOperatorError{
Node: &ast.OperatorNode{Value: kql.BoolAND},
},
},
+3 -4
View File
@@ -2,7 +2,6 @@ package kql
import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
func validateAst(a *ast.Ast) error {
@@ -10,7 +9,7 @@ func validateAst(a *ast.Ast) error {
case *ast.OperatorNode:
switch node.Value {
case BoolAND, BoolOR:
return &query.StartsWithBinaryOperatorError{Node: node}
return &StartsWithBinaryOperatorError{Node: node}
}
}
return nil
@@ -21,14 +20,14 @@ func validateGroupNode(n *ast.GroupNode) error {
case *ast.OperatorNode:
switch node.Value {
case BoolAND, BoolOR:
return &query.StartsWithBinaryOperatorError{Node: node}
return &StartsWithBinaryOperatorError{Node: node}
}
}
if n.Key != "" {
for _, node := range n.Nodes {
if ast.NodeKey(node) != "" {
return &query.NamedGroupInvalidNodesError{Node: node}
return &NamedGroupInvalidNodesError{Node: node}
}
}
}
+10 -121
View File
@@ -9,9 +9,7 @@ import (
"net/http"
"net/url"
"path"
"reflect"
"strconv"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
@@ -28,6 +26,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
)
// CreateUploadSession create an upload session to allow your app to upload files up to the maximum file size.
@@ -452,130 +451,20 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto
}
}
if res.GetArbitraryMetadata() != nil {
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(logger, res)
driveItem.Image = cs3ResourceToDriveItemImageFacet(logger, res)
driveItem.Location = cs3ResourceToDriveItemLocationFacet(logger, res)
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(logger, res)
if metadata := res.GetArbitraryMetadata().GetMetadata(); metadata != nil {
driveItem.Audio = metadataToFacet[libregraph.Audio](metadata, "audio")
driveItem.Image = metadataToFacet[libregraph.Image](metadata, "image")
driveItem.Location = metadataToFacet[libregraph.GeoCoordinates](metadata, "location")
driveItem.Photo = metadataToFacet[libregraph.Photo](metadata, "photo")
}
return driveItem, nil
}
func cs3ResourceToDriveItemAudioFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Audio {
if !strings.HasPrefix(res.GetMimeType(), "audio/") {
return nil
}
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var audio = &libregraph.Audio{}
if ok := unmarshalStringMap(logger, audio, k, "libre.graph.audio."); ok {
return audio
}
return nil
}
func cs3ResourceToDriveItemImageFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Image {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var image = &libregraph.Image{}
if ok := unmarshalStringMap(logger, image, k, "libre.graph.image."); ok {
return image
}
return nil
}
func cs3ResourceToDriveItemLocationFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.GeoCoordinates {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var location = &libregraph.GeoCoordinates{}
if ok := unmarshalStringMap(logger, location, k, "libre.graph.location."); ok {
return location
}
return nil
}
func cs3ResourceToDriveItemPhotoFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Photo {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var photo = &libregraph.Photo{}
if ok := unmarshalStringMap(logger, photo, k, "libre.graph.photo."); ok {
return photo
}
return nil
}
func getFieldName(structField reflect.StructField) string {
tag := structField.Tag.Get("json")
if tag == "" {
return structField.Name
}
return strings.Split(tag, ",")[0]
}
func unmarshalStringMap(logger *log.Logger, out any, flatMap map[string]string, prefix string) bool {
nonEmpty := false
obj := reflect.ValueOf(out).Elem()
timeKind := reflect.TypeOf(&time.Time{}).Elem().Kind()
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
structField := obj.Type().Field(i)
mapKey := prefix + getFieldName(structField)
if value, ok := flatMap[mapKey]; ok {
if field.Kind() == reflect.Ptr {
newValue := reflect.New(field.Type().Elem())
var tmp any
var err error
switch t := newValue.Type().Elem().Kind(); t {
case reflect.String:
tmp = value
case reflect.Int32:
tmp, err = strconv.ParseInt(value, 10, 32)
case reflect.Int64:
tmp, err = strconv.ParseInt(value, 10, 64)
case reflect.Float32:
tmp, err = strconv.ParseFloat(value, 32)
case reflect.Float64:
tmp, err = strconv.ParseFloat(value, 64)
case reflect.Bool:
tmp, err = strconv.ParseBool(value)
case timeKind:
tmp, err = time.Parse(time.RFC3339, value)
default:
err = errors.New("unsupported type")
logger.Error().Err(err).Str("type", t.String()).Str("mapKey", mapKey).Msg("target field type for value of mapKey is not supported")
}
if err != nil {
logger.Error().Err(err).Str("mapKey", mapKey).Msg("unmarshalling failed")
continue
}
newValue.Elem().Set(reflect.ValueOf(tmp).Convert(field.Type().Elem()))
field.Set(newValue)
nonEmpty = true
}
}
}
return nonEmpty
// metadataToFacet builds a DriveItem facet *T from CS3 arbitrary metadata under
// the "libre.graph.<facet>." key prefix. Nil when no such keys are present.
func metadataToFacet[T any](metadata map[string]string, facet string) *T {
return mapping.DeserializeStringsAt[T](metadata, "libre.graph."+facet+".")
}
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
+9 -22
View File
@@ -9,38 +9,24 @@ until you remove it.
### 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`.
Fill the new index by indexing all spaces again:
```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
# the service keeps running while it happens
opencloud search index --all-spaces
```
Once the new index is filled, remove the old one:
Once the new index is filled, every index but the one with the highest
`-v<N>` suffix can go (indexes up to 7.4 have no suffix):
```shell
curl -X DELETE "https://opensearch.example.com:9200/opencloud-resource"
curl "https://os.example.com:9200/_cat/indices/opencloud-resource*"
curl -X DELETE "https://os.example.com:9200/opencloud-resource"
```
### bleve
The new index is the `bleve-v2` directory next to the old `bleve` one, both in
The new index is a 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:
@@ -48,7 +34,8 @@ bleve index cannot be copied, index all spaces again:
opencloud search index --all-spaces
```
Once the new index is filled, remove the old one:
Once the new index is filled, every directory but the one with the highest
`bleve-v<N>` suffix can go (directories up to 7.4 have no suffix):
```shell
rm -r "$OC_BASE_DATA_PATH/search/bleve"
+4 -1
View File
@@ -39,7 +39,10 @@ To enable OpenSearch as a backend, the following settings must be set:
Additionally, the following optional settings can be set:
* `SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME=val` (default: `opencloud-resource`): Name of the OpenSearch index
* `SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME=val` (default:
`opencloud-resource`): base name of the OpenSearch index. The running
index is suffixed with the current schema version; a breaking schema
change targets a fresh index, the old one stays in place.
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_USERNAME=val`: Username for HTTP Basic Authentication.
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_PASSWORD=val`: Password for HTTP Basic Authentication.
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_HEADER=val`: HTTP headers to include in requests.
@@ -137,6 +137,19 @@ func (tc *TestClient) IndicesCreate(ctx context.Context, index string, body io.R
}
}
// IndicesCount returns the number of documents in the given indices.
func (tc *TestClient) IndicesCount(ctx context.Context, indices []string, body io.Reader) (int, error) {
resp, err := tc.c.Indices.Count(ctx, &opensearchgoAPI.IndicesCountReq{
Indices: indices,
Body: body,
})
if err != nil {
return 0, fmt.Errorf("failed to count documents in %v: %w", indices, err)
}
return resp.Count, nil
}
type testRequireClient struct {
tc *TestClient
t testing.TB
@@ -157,3 +170,9 @@ 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, want int) {
got, err := trc.tc.IndicesCount(trc.t.Context(), indices, body)
require.NoError(trc.t, err)
require.Equal(trc.t, want, got)
}
@@ -8,7 +8,7 @@
"Name" : "dummy name",
"Content" : "dummy content",
"Size" : 42,
"Mtime" : "2025-07-24 15:15:01.324093 +0200 CEST m=+0.000056251",
"Mtime" : "2025-07-24T15:15:01.324093+02:00",
"MimeType" : "image/jpeg",
"Tags" : [ "dummy" ],
"Deleted" : false,
@@ -3,5 +3,6 @@
"RootID" : "1$1!1",
"ParentID" : "1$1!1",
"Path" : "./parent d!r",
"Type" : 2
"Type" : 2,
"Mtime" : "2025-07-24T15:15:01.324093+02:00"
}
@@ -1,5 +1,6 @@
{
"ID" : "1$1!1",
"RootID" : "1$1!1",
"Path" : "."
"Path" : ".",
"Mtime" : "2025-07-24T15:15:01.324093+02:00"
}
+16 -17
View File
@@ -3,7 +3,6 @@ package bleve
import (
"context"
"math"
"strings"
"time"
"github.com/blevesearch/bleve/v2"
@@ -14,6 +13,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
@@ -45,7 +45,7 @@ func NewBackend(index bleve.Index, queryCreator searchQuery.Creator[query.Query]
func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
createdQuery, err := b.queryCreator.Create(sir.Query)
if err != nil {
if searchQuery.IsValidationError(err) {
if kql.IsValidationError(err) {
return nil, errtypes.BadRequest(err.Error())
}
return nil, err
@@ -74,6 +74,16 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
),
},
)
// Scope below the space root: restrict at query level so totals and
// paging respect the path too. Path is a case-preserving keyword
// (paths act as references, /Foo and /foo are distinct), so the exact
// folder or the folder prefix matches all of, and only, the scope.
if requestedPath := utils.MakeRelativePath(sir.Ref.Path); requestedPath != "." {
q.Conjuncts = append(q.Conjuncts, query.NewDisjunctionQuery([]query.Query{
&query.TermQuery{FieldVal: "Path", Term: requestedPath},
&query.PrefixQuery{FieldVal: "Path", Prefix: requestedPath + "/"},
}))
}
}
bleveReq := bleve.NewSearchRequest(q)
@@ -97,17 +107,6 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
matches := make([]*searchMessage.Match, 0, len(res.Hits))
totalMatches := res.Total
for _, hit := range res.Hits {
if sir.Ref != nil {
hitPath := strings.TrimSuffix(getFieldValue[string](hit.Fields, "Path"), "/")
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
isRoot := hitPath == requestedPath
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
totalMatches--
continue
}
}
rootID, err := storagespace.ParseID(getFieldValue[string](hit.Fields, "RootID"))
if err != nil {
return nil, err
@@ -136,10 +135,10 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
Tags: getFieldSliceValue[string](hit.Fields, "Tags"),
Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"),
Highlights: getFragmentValue(hit.Fragments, "Content", 0),
Audio: getAudioValue[searchMessage.Audio](hit.Fields),
Image: getImageValue[searchMessage.Image](hit.Fields),
Location: getLocationValue[searchMessage.GeoCoordinates](hit.Fields),
Photo: getPhotoValue[searchMessage.Photo](hit.Fields),
Audio: hitToFacet[searchMessage.Audio](hit.Fields, "audio"),
Image: hitToFacet[searchMessage.Image](hit.Fields, "image"),
Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"),
Photo: hitToFacet[searchMessage.Photo](hit.Fields, "photo"),
},
}
+118
View File
@@ -0,0 +1,118 @@
package bleve_test
import (
"fmt"
bleveSearch "github.com/blevesearch/bleve/v2"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
bleveQuery "github.com/opencloud-eu/opencloud/services/search/pkg/query/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var _ = Describe("Bleve", func() {
var (
eng *bleve.Backend
idx bleveSearch.Index
rootResource search.Resource
parentResource search.Resource
childResource search.Resource
)
BeforeEach(func() {
mapping, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
idx, err = bleveSearch.NewMemOnly(mapping)
Expect(err).ToNot(HaveOccurred())
eng = bleve.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
Expect(err).ToNot(HaveOccurred())
rootResource = search.Resource{
ID: "1$2!2",
RootID: "1$2!2",
Path: ".",
Document: content.Document{},
}
parentResource = search.Resource{
ID: "1$2!3",
ParentID: rootResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER),
Document: content.Document{Name: "parent d!r"},
}
childResource = search.Resource{
ID: "1$2!4",
ParentID: parentResource.ID,
RootID: rootResource.ID,
Path: "./parent d!r/child.pdf",
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
Document: content.Document{Name: "child.pdf"},
}
})
Describe("PurgeSpace", func() {
It("takes every record of that space out of the index", func() {
otherSpace := search.Resource{
ID: "1$9!9",
RootID: "1$9!9",
Path: ".",
Document: content.Document{Name: "other"},
}
for _, resource := range []search.Resource{rootResource, parentResource, childResource, otherSpace} {
Expect(eng.Upsert(resource.ID, resource)).To(Succeed())
}
Expect(eng.PurgeSpace(rootResource.RootID)).To(Succeed())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)), "only the records of that space are gone")
})
It("takes a space out that holds more records than one round", func() {
otherSpace := search.Resource{
ID: "1$9!9",
RootID: "1$9!9",
Path: ".",
Document: content.Document{Name: "other"},
}
Expect(eng.Upsert(otherSpace.ID, otherSpace)).To(Succeed())
for i := range 120 {
resource := search.Resource{
ID: fmt.Sprintf("%s!file-%d", rootResource.RootID, i),
RootID: rootResource.RootID,
Path: fmt.Sprintf("./file-%d", i),
Document: content.Document{Name: fmt.Sprintf("file-%d", i)},
}
Expect(eng.Upsert(resource.ID, resource)).To(Succeed())
}
Expect(eng.PurgeSpace(rootResource.RootID)).To(Succeed())
count, err := idx.DocCount()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(uint64(1)), "only the record of the other space is left")
})
})
Describe("New", func() {
It("returns a new index instance", func() {
b := bleve.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
Expect(b).ToNot(BeNil())
})
})
})
+18 -6
View File
@@ -10,6 +10,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -36,18 +37,29 @@ func NewBatch(index bleve.Index, size int) (*Batch, error) {
func (b *Batch) Upsert(id string, r search.Resource) error {
return b.withSizeLimit(func() error {
return b.batch.Index(id, r)
return b.indexResource(id, r)
})
}
func (b *Batch) Move(id string, parentID string, targetPath string) error {
// indexResource prepares r for bleve (resolving json tags and splicing in
// type-specific adaptations via the mapping package) and appends it to the
// batch under id.
func (b *Batch) indexResource(id string, r search.Resource) error {
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
if err != nil {
return err
}
return b.batch.Index(id, doc)
}
func (b *Batch) Move(id, parentID, location string) error {
return b.withSizeLimit(func() error {
rootResource, err := searchResourceByID(id, b.index)
if err != nil {
return err
}
currentPath := rootResource.Path
nextPath := utils.MakeRelativePath(targetPath)
nextPath := utils.MakeRelativePath(location)
rootResource.Path = nextPath
rootResource.Name = path.Base(nextPath)
@@ -70,7 +82,7 @@ func (b *Batch) Move(id string, parentID string, targetPath string) error {
for _, resource := range resources {
resource.Hidden = search.IsHidden(resource.Path)
if err := b.batch.Index(resource.ID, resource); err != nil {
if err := b.indexResource(resource.ID, *resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
@@ -92,7 +104,7 @@ func (b *Batch) Delete(id string) error {
}
for _, resource := range affectedResources {
if err := b.batch.Index(resource.ID, resource); err != nil {
if err := b.indexResource(resource.ID, *resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
@@ -114,7 +126,7 @@ func (b *Batch) Restore(id string) error {
}
for _, resource := range affectedResources {
if err := b.batch.Index(resource.ID, resource); err != nil {
if err := b.indexResource(resource.ID, *resource); err != nil {
return err
}
if b.batch.Size() >= b.size {
+11 -129
View File
@@ -1,18 +1,13 @@
package bleve
import (
"reflect"
"regexp"
"strings"
"time"
bleveSearch "github.com/blevesearch/bleve/v2/search"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"google.golang.org/protobuf/types/known/timestamppb"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -75,132 +70,19 @@ func getFragmentValue(m bleveSearch.FieldFragmentMap, key string, idx int) strin
return val[idx]
}
func getAudioValue[T any](fields map[string]any) *T {
if !strings.HasPrefix(getFieldValue[string](fields, "MimeType"), "audio/") {
return nil
}
var audio = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(audio, fields, "audio."); ok {
return audio
}
return nil
}
func getImageValue[T any](fields map[string]any) *T {
var image = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(image, fields, "image."); ok {
return image
}
return nil
}
func getLocationValue[T any](fields map[string]any) *T {
var location = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(location, fields, "location."); ok {
return location
}
return nil
}
func getPhotoValue[T any](fields map[string]any) *T {
var photo = newPointerOfType[T]()
if ok := unmarshalInterfaceMap(photo, fields, "photo."); ok {
return photo
}
return nil
}
func newPointerOfType[T any]() *T {
t := reflect.TypeOf((*T)(nil)).Elem()
ptr := reflect.New(t).Interface()
return ptr.(*T)
}
func unmarshalInterfaceMap(out any, flatMap map[string]any, prefix string) bool {
nonEmpty := false
obj := reflect.ValueOf(out).Elem()
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
structField := obj.Type().Field(i)
mapKey := prefix + getFieldName(structField)
if value, ok := flatMap[mapKey]; ok {
if field.Kind() == reflect.Ptr {
alloc := reflect.New(field.Type().Elem())
elemType := field.Type().Elem()
// convert time strings from index for search requests
if elemType == reflect.TypeOf(timestamppb.Timestamp{}) {
if strValue, ok := value.(string); ok {
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
alloc.Elem().Set(reflect.ValueOf(*timestamppb.New(parsedTime)))
field.Set(alloc)
nonEmpty = true
}
}
continue
}
// convert time strings from index for libregraph structs when updating resources
if elemType == reflect.TypeOf(time.Time{}) {
if strValue, ok := value.(string); ok {
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
alloc.Elem().Set(reflect.ValueOf(parsedTime))
field.Set(alloc)
nonEmpty = true
}
}
continue
}
alloc.Elem().Set(reflect.ValueOf(value).Convert(elemType))
field.Set(alloc)
nonEmpty = true
}
}
}
return nonEmpty
}
func getFieldName(structField reflect.StructField) string {
tag := structField.Tag.Get("json")
if tag == "" {
return structField.Name
}
return strings.Split(tag, ",")[0]
// hitToFacet builds a search Entity facet *T from a bleve hit's fields under the
// given key prefix. Nil when the hit has no such fields.
func hitToFacet[T any](fields map[string]any, prefix string) *T {
return mapping.DeserializeAt[T](fields, prefix)
}
// matchToResource reconstructs a search.Resource from a bleve hit. Used by
// the Move / Delete / Restore / Purge paths that round-trip a record through
// the index. Always returns a non-nil *Resource: Deserialize is fail-soft
// for per-field parse errors, so corrupted hit values surface as zero
// values on individual fields instead of dropping the whole record.
func matchToResource(match *bleveSearch.DocumentMatch) *search.Resource {
return &search.Resource{
ID: getFieldValue[string](match.Fields, "ID"),
RootID: getFieldValue[string](match.Fields, "RootID"),
Path: getFieldValue[string](match.Fields, "Path"),
ParentID: getFieldValue[string](match.Fields, "ParentID"),
Type: uint64(getFieldValue[float64](match.Fields, "Type")),
Deleted: getFieldValue[bool](match.Fields, "Deleted"),
Hidden: getFieldValue[bool](match.Fields, "Hidden"),
Document: content.Document{
Name: getFieldValue[string](match.Fields, "Name"),
Title: getFieldValue[string](match.Fields, "Title"),
Size: uint64(getFieldValue[float64](match.Fields, "Size")),
Mtime: getFieldValue[string](match.Fields, "Mtime"),
MimeType: getFieldValue[string](match.Fields, "MimeType"),
Content: getFieldValue[string](match.Fields, "Content"),
Tags: getFieldSliceValue[string](match.Fields, "Tags"),
Favorites: getFieldSliceValue[string](match.Fields, "Favorites"),
Audio: getAudioValue[libregraph.Audio](match.Fields),
Image: getImageValue[libregraph.Image](match.Fields),
Location: getLocationValue[libregraph.GeoCoordinates](match.Fields),
Photo: getPhotoValue[libregraph.Photo](match.Fields),
},
}
return mapping.Deserialize[search.Resource](match.Fields)
}
func escapeQuery(s string) string {
@@ -0,0 +1,127 @@
package bleve_test
import (
bleveSearch "github.com/blevesearch/bleve/v2"
"github.com/blevesearch/bleve/v2/search/query"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// geoFixture builds an in-memory bleve index with a single resource carrying
// the given lon/lat/alt, indexed through the full bleve pipeline.
func geoFixture(lon, lat, alt float64) bleveSearch.Index {
idxMapping, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
idx, err := bleveSearch.NewMemOnly(idxMapping)
Expect(err).ToNot(HaveOccurred())
r := search.Resource{
ID: "x",
Document: content.Document{
Name: "team.jpg",
Location: &libregraph.GeoCoordinates{
Longitude: &lon,
Latitude: &lat,
Altitude: &alt,
},
},
}
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
Expect(err).ToNot(HaveOccurred())
Expect(idx.Index(r.ID, doc)).To(Succeed())
return idx
}
var _ = Describe("Location geo queries", func() {
// Every Location subfield (including altitude) must end up in hit.Fields
// when a Resource is indexed through the full bleve pipeline. This is the
// invariant the Move / Delete / Restore round-trip depends on.
It("round-trips every Location subfield into hit.Fields", func() {
idx := geoFixture(11.103870357204285, 49.48675890884328, 1047.7)
req := bleveSearch.NewSearchRequest(bleveSearch.NewMatchAllQuery())
req.Fields = []string{"*"}
res, err := idx.Search(req)
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).ToNot(BeEmpty())
for _, k := range []string{"location.longitude", "location.latitude", "location.altitude"} {
Expect(res.Hits[0].Fields).To(HaveKey(k))
}
})
It("matches a latitude numeric range and misses outside it", func() {
idx := geoFixture(11.1, 49.48, 1000)
min, max := 49.0, 50.0
incl := true
q := query.NewNumericRangeInclusiveQuery(&min, &max, &incl, &incl)
q.SetField("location.latitude")
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
lowMin, lowMax := 0.0, 10.0
q2 := query.NewNumericRangeInclusiveQuery(&lowMin, &lowMax, &incl, &incl)
q2.SetField("location.latitude")
res, err = idx.Search(bleveSearch.NewSearchRequest(q2))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(BeEmpty())
})
It("matches a longitude numeric range", func() {
idx := geoFixture(11.1, 49.48, 1000)
min, max := 11.0, 12.0
incl := true
q := query.NewNumericRangeInclusiveQuery(&min, &max, &incl, &incl)
q.SetField("location.longitude")
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
})
It("matches an altitude lower-bound range and misses above it", func() {
idx := geoFixture(11.1, 49.48, 1047.7)
min := 1000.0
incl := true
q := query.NewNumericRangeInclusiveQuery(&min, nil, &incl, nil)
q.SetField("location.altitude")
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
highMin := 2000.0
q2 := query.NewNumericRangeInclusiveQuery(&highMin, nil, &incl, nil)
q2.SetField("location.altitude")
res, err = idx.Search(bleveSearch.NewSearchRequest(q2))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(BeEmpty())
})
It("matches a geo-distance query near the point and misses far away", func() {
// Nuremberg-ish coordinates.
idx := geoFixture(11.103870357204285, 49.48675890884328, 1047.7)
// 10 km radius around the indexed point should match.
near := query.NewGeoDistanceQuery(11.103870357204285, 49.48675890884328, "10km")
near.SetField("location" + mapping.GeopointSuffix)
res, err := idx.Search(bleveSearch.NewSearchRequest(near))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(HaveLen(1))
// Far away (Berlin, ~400 km) with a 10 km radius should miss.
far := query.NewGeoDistanceQuery(13.404954, 52.520008, "10km")
far.SetField("location" + mapping.GeopointSuffix)
res, err = idx.Search(bleveSearch.NewSearchRequest(far))
Expect(err).ToNot(HaveOccurred())
Expect(res.Hits).To(BeEmpty())
})
})
+24 -73
View File
@@ -2,33 +2,26 @@ package bleve
import (
"errors"
"fmt"
"math"
"path/filepath"
"reflect"
"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/char/regexp"
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/single"
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
"github.com/blevesearch/bleve/v2/mapping"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"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 := indexPath(root)
destination := filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion))
index, err := bleve.Open(destination)
if errors.Is(err, bleve.ErrorIndexPathDoesNotExist) {
indexMapping, err := NewMapping()
@@ -47,77 +40,35 @@ func NewIndex(root string) (bleve.Index, error) {
}
func NewMapping() (mapping.IndexMapping, error) {
words := func() *mapping.FieldMapping {
fm := bleve.NewTextFieldMapping()
fm.Analyzer = "lowercaseWords"
return fm
resourceType := reflect.TypeFor[search.Resource]()
overrides := search.Resource{}.SearchFieldOverrides()
if err := searchmapping.Validate(resourceType, overrides); err != nil {
return nil, err
}
whole := func(field string) *mapping.FieldMapping {
fm := bleve.NewTextFieldMapping()
fm.Analyzer = "lowercaseKeyword"
fm.IncludeInAll = false
fm.Name = field + wildcardSuffix
return fm
docMapping, err := searchmapping.BleveBuildMapping(resourceType, overrides)
if err != nil {
return nil, err
}
lowercaseMapping := bleve.NewTextFieldMapping()
lowercaseMapping.IncludeInAll = false
lowercaseMapping.Analyzer = "lowercaseKeyword"
contentMapping := words()
contentMapping.IncludeInAll = false
docMapping := bleve.NewDocumentMapping()
docMapping.AddFieldMappingsAt("Name",
words(),
whole("Name"),
)
docMapping.AddFieldMappingsAt("Title",
words(),
whole("Title"),
)
docMapping.AddFieldMappingsAt("Tags", lowercaseMapping)
docMapping.AddFieldMappingsAt("Favorites", lowercaseMapping)
docMapping.AddFieldMappingsAt("Content", contentMapping)
indexMapping := bleve.NewIndexMapping()
indexMapping.DefaultAnalyzer = keyword.Name
indexMapping.DefaultMapping = docMapping
err := indexMapping.AddCustomCharFilter("dotToSpace",
map[string]any{
"type": regexpCharFilter.Name,
"regexp": `\.`,
"replace": " ",
},
)
// words: split into lowercased words, a dot is a word boundary too so that
// "report" finds "Report.txt"; no stemming, a name is not prose
err = indexMapping.AddCustomCharFilter("dot_to_space", map[string]any{
"type": regexp.Name,
"regexp": `\.`,
"replace": " ",
})
if err != nil {
return nil, err
}
err = indexMapping.AddCustomAnalyzer("lowercaseWords",
err = indexMapping.AddCustomAnalyzer(searchmapping.WordsAnalyzer,
map[string]any{
"type": custom.Name,
"char_filters": []string{"dotToSpace"},
"tokenizer": unicode.Name,
"token_filters": []string{
lowercase.Name,
},
},
)
if err != nil {
return nil, err
}
err = indexMapping.AddCustomAnalyzer("lowercaseKeyword",
map[string]any{
"type": custom.Name,
"tokenizer": single.Name,
"token_filters": []string{
lowercase.Name,
},
"type": custom.Name,
"char_filters": []string{"dot_to_space"},
"tokenizer": unicode.Name,
"token_filters": []string{lowercase.Name},
},
)
if err != nil {
+5 -3
View File
@@ -1,12 +1,14 @@
package bleve_test
import (
"fmt"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var _ = Describe("Index", func() {
@@ -18,8 +20,8 @@ var _ = Describe("Index", func() {
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())
Expect(index.Name()).To(Equal(filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion))))
Expect(filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion))).To(BeADirectory())
})
It("opens the index that is already there", func() {
@@ -33,7 +35,7 @@ var _ = Describe("Index", func() {
Expect(err).ToNot(HaveOccurred())
DeferCleanup(reopened.Close)
Expect(reopened.Name()).To(Equal(filepath.Join(root, "bleve-v2")))
Expect(reopened.Name()).To(Equal(filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion))))
})
})
})
+41
View File
@@ -0,0 +1,41 @@
package bleve_test
import (
"time"
"github.com/opencloud-eu/opencloud/pkg/conversions"
bleveSearch "github.com/blevesearch/bleve/v2"
bquery "github.com/blevesearch/bleve/v2/search/query"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// Mtime is typed as a date, so range queries are chronological, not a
// lexicographic keyword compare.
var _ = Describe("Mtime date range", func() {
It("compares chronologically, not lexicographically", func() {
m, err := bleve.NewMapping()
Expect(err).ToNot(HaveOccurred())
idx, err := bleveSearch.NewMemOnly(m)
Expect(err).ToNot(HaveOccurred())
r := search.Resource{ID: "x", Document: content.Document{Name: "f", Mtime: conversions.ToPointer(time.Date(2026, 3, 15, 12, 0, 0, 123456789, time.UTC))}}
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
Expect(err).ToNot(HaveOccurred())
Expect(idx.Index(r.ID, doc)).To(Succeed())
hits := func(qs string) uint64 {
res, err := idx.Search(bleveSearch.NewSearchRequest(bquery.NewQueryStringQuery(qs)))
Expect(err).ToNot(HaveOccurred(), qs)
return res.Total
}
Expect(hits(`Mtime:>"2026-01-01T00:00:00Z"`)).To(Equal(uint64(1)), "in-range")
Expect(hits(`Mtime:>"2026-06-01T00:00:00Z"`)).To(Equal(uint64(0)), "out-of-range")
})
})
+2 -1
View File
@@ -119,7 +119,8 @@ func Server(cfg *config.Config) *cobra.Command {
return fmt.Errorf("failed to create OpenSearch client: %w", err)
}
openSearchBackend, err := opensearch.NewBackend(cfg.Engine.OpenSearch.ResourceIndex.Name, client)
indexName := opensearch.VersionedIndexName(cfg.Engine.OpenSearch.ResourceIndex.Name)
openSearchBackend, err := opensearch.NewBackend(indexName, client)
if err != nil {
return fmt.Errorf("failed to create OpenSearch backend: %w", err)
}
+1 -1
View File
@@ -25,7 +25,7 @@ type EngineOpenSearch struct {
// EngineOpenSearchResourceIndex defines the OpenSearch index for resources
type EngineOpenSearchResourceIndex struct {
Name string `yaml:"name" env:"SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME" desc:"The name of the OpenSearch index for resources." introductionVersion:"4.0.0"`
Name string `yaml:"name" env:"SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME" desc:"The base name of the OpenSearch index for resources. The running index is suffixed with the current schema version." introductionVersion:"4.0.0"`
}
// EngineOpenSearchClient configures the OpenSearch client
+2 -2
View File
@@ -3,9 +3,9 @@ package content
import (
"context"
"encoding/json"
"time"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/reva/v2/pkg/tags"
"github.com/opencloud-eu/reva/v2/pkg/utils"
@@ -54,7 +54,7 @@ func (b Basic) Extract(_ context.Context, ri *storageProvider.ResourceInfo) (Doc
}
if ri.Mtime != nil {
doc.Mtime = utils.TSToTime(ri.Mtime).UTC().Format(time.RFC3339Nano)
doc.Mtime = conversions.ToPointer(utils.TSToTime(ri.Mtime).UTC())
}
return doc, nil
+8 -4
View File
@@ -1,6 +1,10 @@
package content_test
import (
"time"
"github.com/opencloud-eu/opencloud/pkg/conversions"
"context"
"encoding/json"
@@ -69,11 +73,11 @@ var _ = Describe("Basic", func() {
It("RFC3339 mtime", func() {
for _, data := range []struct {
second uint64
expect string
expect *time.Time
}{
{second: 4000, expect: "1970-01-01T01:06:40Z"},
{second: 3000, expect: "1970-01-01T00:50:00Z"},
{expect: ""},
{second: 4000, expect: conversions.ToPointer(time.Unix(4000, 0).UTC())},
{second: 3000, expect: conversions.ToPointer(time.Unix(3000, 0).UTC())},
{},
} {
ri := &storageProvider.ResourceInfo{}
+9 -8
View File
@@ -2,6 +2,7 @@ package content
import (
"strings"
"time"
"github.com/bbalet/stopwords"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
@@ -14,14 +15,14 @@ func init() {
// Document wraps all resource meta fields,
// it is used as a content extraction result.
type Document struct {
Title string
Name string
Content string
Size uint64
Mtime string `json:"Mtime,omitempty"`
MimeType string
Tags []string
Favorites []string
Title string `json:"Title"`
Name string `json:"Name"`
Content string `json:"Content"`
Size uint64 `json:"Size"`
Mtime *time.Time `json:"Mtime,omitempty"`
MimeType string `json:"MimeType"`
Tags []string `json:"Tags"`
Favorites []string `json:"Favorites"`
Audio *libregraph.Audio `json:"audio,omitempty"`
Image *libregraph.Image `json:"image,omitempty"`
Location *libregraph.GeoCoordinates `json:"location,omitempty"`
+142
View File
@@ -0,0 +1,142 @@
package mapping
import (
"fmt"
"reflect"
"github.com/blevesearch/bleve/v2"
bleveMapping "github.com/blevesearch/bleve/v2/mapping"
)
// BleveBuildMapping builds a bleve DocumentMapping for t by walking the
// struct via reflection. Field names come from json tags; overrides are
// keyed by those names (or dotted paths for nested fields).
//
// The returned mapping references the words analyzer for Fulltext fields;
// the caller registers it on the enclosing IndexMapping.
func BleveBuildMapping(t reflect.Type, overrides map[string]FieldOpts) (*bleveMapping.DocumentMapping, error) {
return buildBleveDocMapping(t, overrides, "")
}
func buildBleveDocMapping(t reflect.Type, overrides map[string]FieldOpts, prefix string) (*bleveMapping.DocumentMapping, error) {
doc := bleve.NewDocumentMapping()
err := walkFields(t, func(fi fieldInfo) error {
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
opts := overrides[key]
fieldType := opts.Type
if fieldType == "" {
fieldType = inferType(fi.GoField.Type)
}
if fieldType == TypeObject {
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: object type on non-struct field %q", key)
}
subDoc, err := buildBleveDocMapping(sub, overrides, key)
if err != nil {
return err
}
doc.AddSubDocumentMapping(fi.Name, subDoc)
return nil
}
if fieldType == TypeGeopoint {
// Keep the facet object, add a sibling _geopoint field (see GeopointSuffix).
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: geopoint type on non-struct field %q", key)
}
subDoc, err := buildBleveDocMapping(sub, overrides, key)
if err != nil {
return err
}
doc.AddSubDocumentMapping(fi.Name, subDoc)
doc.AddFieldMappingsAt(fi.Name+GeopointSuffix, bleve.NewGeoPointFieldMapping())
return nil
}
if fieldType == TypeKeyword || fieldType == TypePath {
// bleve has no path tokenizer, so a path is a plain keyword here.
base := bleveKeywordMapping(fieldType, opts)
doc.AddFieldMappingsAt(fi.Name, base)
if opts.caseInsensitive() {
doc.AddFieldMappingsAt(fi.Name+LowercaseSuffix, searchSibling(base))
}
if fieldType == TypeKeyword && opts.wordBroken() {
words := searchSibling(base)
words.Analyzer = WordsAnalyzer
doc.AddFieldMappingsAt(fi.Name+WordsSuffix, words)
}
return nil
}
fm, err := bleveFieldMapping(fieldType, opts)
if err != nil {
return fmt.Errorf("mapping: field %q: %w", key, err)
}
doc.AddFieldMappingsAt(fi.Name, fm)
return nil
})
return doc, err
}
// bleveKeywordMapping is a case-preserving keyword field; path fields stay out
// of _all by default.
func bleveKeywordMapping(fieldType string, opts FieldOpts) *bleveMapping.FieldMapping {
fm := bleve.NewKeywordFieldMapping()
switch {
case opts.IncludeInAll != nil:
fm.IncludeInAll = *opts.IncludeInAll
case fieldType == TypePath:
fm.IncludeInAll = false
}
return fm
}
// searchSibling derives a search-only shadow of a keyword/path field from its
// base mapping (the _lowercase and _words siblings): indexed but never stored,
// kept out of _all, and without doc values, since the case-preserved base field
// is what we return and aggregate on.
func searchSibling(base *bleveMapping.FieldMapping) *bleveMapping.FieldMapping {
fm := *base
fm.Store = false
fm.IncludeInAll = false
fm.DocValues = false
return &fm
}
func bleveFieldMapping(fieldType string, opts FieldOpts) (*bleveMapping.FieldMapping, error) {
switch fieldType {
case TypeWildcard:
// bleve has no wildcard type; fall back to keyword-ish text.
fieldType = TypeKeyword
fallthrough
case TypeKeyword, TypeFulltext:
fm := bleve.NewTextFieldMapping()
if fieldType == TypeFulltext {
fm.Analyzer = WordsAnalyzer
}
switch {
case opts.IncludeInAll != nil:
fm.IncludeInAll = *opts.IncludeInAll
case fieldType == TypeFulltext:
fm.IncludeInAll = false
}
return fm, nil
case TypeNumeric:
return bleve.NewNumericFieldMapping(), nil
case TypeBool:
return bleve.NewBooleanFieldMapping(), nil
case TypeDatetime:
return bleve.NewDateTimeFieldMapping(), nil
case TypeGeopoint:
return bleve.NewGeoPointFieldMapping(), nil
case "":
return nil, fmt.Errorf("no type inferred and no override")
}
return nil, fmt.Errorf("unsupported type %q", fieldType)
}
+138
View File
@@ -0,0 +1,138 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type bleveDoc struct {
Name string `json:"Name"`
Content string `json:"Content"`
Tags []string `json:"Tags"`
Size uint64 `json:"Size"`
Deleted bool `json:"Deleted"`
CreatedAt time.Time `json:"CreatedAt"`
Nested *nested `json:"nested,omitempty"`
}
type nested struct {
Artist string `json:"artist"`
Year int `json:"year"`
}
var _ = Describe("BleveBuildMapping", func() {
It("falls back to text for wildcard fields", func() {
// bleve wildcard falls back to keyword-ish text (bleve has no wildcard type).
type doc struct {
Mime string `json:"mime"`
}
dm, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"mime": {Type: TypeWildcard}})
Expect(err).ToNot(HaveOccurred())
fms := dm.Properties["mime"].Fields
Expect(fms).To(HaveLen(1))
Expect(fms[0].Type).To(Equal("text"), "wildcard should map to text")
})
DescribeTable("infers field types",
func(field, wantType string) {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
prop := dm.Properties[field]
Expect(prop).ToNot(BeNil(), "missing property %q", field)
Expect(prop.Fields).ToNot(BeEmpty(), "%q: no field mappings", field)
Expect(prop.Fields[0].Type).To(Equal(wantType), "%q type", field)
},
Entry("Name", "Name", "text"),
Entry("Content", "Content", "text"),
Entry("Tags", "Tags", "text"),
Entry("Size", "Size", "number"),
Entry("Deleted", "Deleted", "boolean"),
Entry("CreatedAt", "CreatedAt", "datetime"),
)
It("maps a nested struct as a sub-document", func() {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
sub := dm.Properties["nested"]
Expect(sub).ToNot(BeNil(), "missing nested sub-document")
Expect(sub.Properties["artist"]).ToNot(BeNil())
Expect(sub.Properties["year"]).ToNot(BeNil())
Expect(sub.Properties["artist"].Fields[0].Type).To(Equal("text"), "nested.artist")
Expect(sub.Properties["year"].Fields[0].Type).To(Equal("number"), "nested.year")
})
It("applies field overrides", func() {
True, False := true, false
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Content": {Type: TypeFulltext},
"Tags": {CaseInsensitive: &True, IncludeInAll: &False},
})
Expect(err).ToNot(HaveOccurred())
// Name: case-preserved base keyword + lowercased sibling.
Expect(dm.Properties["Name"]).ToNot(BeNil(), "Name base field")
Expect(dm.Properties["Name"].Fields[0].Analyzer).To(Equal("keyword"), "Name base is a keyword")
Expect(dm.Properties["Name"].Fields[0].Store).To(BeTrue(), "Name base is stored (returned)")
Expect(dm.Properties["Name_lowercase"]).ToNot(BeNil(), "Name_lowercase sibling")
// The sibling is a search-only shadow: indexed but never stored, kept out
// of _all, no doc values (the base is what we return).
sibling := dm.Properties["Name_lowercase"].Fields[0]
Expect(sibling.Index).To(BeTrue(), "Name_lowercase is indexed")
Expect(sibling.Store).To(BeFalse(), "Name_lowercase is not stored")
Expect(sibling.IncludeInAll).To(BeFalse(), "Name_lowercase is out of _all")
Expect(sibling.DocValues).To(BeFalse(), "Name_lowercase has no doc values")
contentField := dm.Properties["Content"].Fields[0]
Expect(contentField.Analyzer).To(Equal(WordsAnalyzer), "Content analyzer")
Expect(contentField.IncludeInAll).To(BeFalse(), "Content IncludeInAll should default to false for the fulltext type")
// Tags: base + lowercased sibling, both honoring the IncludeInAll override.
Expect(dm.Properties["Tags"].Fields[0].IncludeInAll).To(BeFalse(), "Tags base IncludeInAll honored")
Expect(dm.Properties["Tags_lowercase"].Fields[0].IncludeInAll).To(BeFalse(), "Tags sibling IncludeInAll honored")
})
It("gives a keyword its lowercase and words siblings by default", func() {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
// the base stays a keyword, the words go to a search-only sibling
Expect(dm.Properties["Name"].Fields[0].Analyzer).To(Equal("keyword"), "Name base stays a keyword")
Expect(dm.Properties["Name"].Fields[0].Store).To(BeTrue(), "Name base is stored (returned)")
Expect(dm.Properties["Name_lowercase"].Fields[0].Analyzer).To(Equal("keyword"), "Name_lowercase stays a keyword")
words := dm.Properties["Name_words"].Fields[0]
Expect(words.Analyzer).To(Equal(WordsAnalyzer), "Name_words is split into words")
Expect(words.Store).To(BeFalse(), "Name_words is not stored")
Expect(words.IncludeInAll).To(BeFalse(), "Name_words is out of _all")
})
It("builds an object sub-document plus a geopoint sibling", func() {
type geoDoc struct {
Location *struct {
Lon *float64 `json:"longitude,omitempty"`
Lat *float64 `json:"latitude,omitempty"`
Alt *float64 `json:"altitude,omitempty"`
} `json:"location,omitempty"`
}
dm, err := BleveBuildMapping(reflect.TypeFor[geoDoc](), map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Original facet stays as an object sub-document with numeric
// sub-properties - for data retrieval via hit.Fields and ordinary
// numeric queries.
loc := dm.Properties["location"]
Expect(loc).ToNot(BeNil(), "location sub-document missing")
Expect(loc.Fields).To(BeEmpty(), "location should not carry field mappings directly")
for _, sub := range []string{"longitude", "latitude", "altitude"} {
prop, ok := loc.Properties[sub]
Expect(ok).To(BeTrue(), "missing sub-field %q under location", sub)
Expect(prop.Fields).ToNot(BeEmpty(), "location.%s Fields", sub)
Expect(prop.Fields[0].Type).To(Equal("number"), "location.%s type", sub)
}
// Sibling geopoint at "<name>_geopoint" for geo-distance queries.
sibling := dm.Properties["location"+GeopointSuffix]
Expect(sibling).ToNot(BeNil(), "location%s missing", GeopointSuffix)
Expect(sibling.Fields).ToNot(BeEmpty(), "location%s Fields", GeopointSuffix)
Expect(sibling.Fields[0].Type).To(Equal("geopoint"), "location%s type", GeopointSuffix)
})
})
+100
View File
@@ -0,0 +1,100 @@
package mapping
import (
"reflect"
"strings"
)
// addSearchSiblings writes the _lowercase and _words siblings next to their
// base values, for every keyword/path field of t that has them (see
// SearchSiblings).
func addSearchSiblings(m map[string]any, t reflect.Type, overrides map[string]FieldOpts) {
for key, siblings := range SearchSiblings(t, overrides) {
parent, leaf, ok := resolveLeaf(m, key)
if !ok {
continue
}
if siblings.Lowercase {
addLowercaseSibling(parent, leaf)
}
if siblings.Words {
addWordsSibling(parent, leaf)
}
}
}
// Siblings says which search-only siblings a field carries.
type Siblings struct {
Lowercase bool
Words bool
}
// SearchSiblings lists the fields of t (json names, nested as parent.child)
// that carry a _lowercase or _words sibling, from the effective field type and
// the overrides. It is the one place that decides, the renderers, the
// document writer and the query lowering all follow it.
func SearchSiblings(t reflect.Type, overrides map[string]FieldOpts) map[string]Siblings {
out := map[string]Siblings{}
for key, goType := range collectFields(t, "") {
opts := overrides[key]
eff := opts.Type
if eff == "" {
eff = inferType(goType)
}
if eff != TypeKeyword && eff != TypePath {
continue
}
siblings := Siblings{
Lowercase: opts.caseInsensitive(),
Words: eff == TypeKeyword && opts.wordBroken(),
}
if siblings.Lowercase || siblings.Words {
out[key] = siblings
}
}
return out
}
// addWordsSibling copies the value to a <leaf>_words sibling; the words
// analyzer does the splitting and lowercasing. No-op for non-strings.
func addWordsSibling(parent map[string]any, leaf string) {
switch v := parent[leaf].(type) {
case string, []any, []string:
parent[leaf+WordsSuffix] = v
}
}
func resolveLeaf(m map[string]any, dottedPath string) (map[string]any, string, bool) {
parts := strings.Split(dottedPath, ".")
parent := m
for _, p := range parts[:len(parts)-1] {
next, ok := parent[p].(map[string]any)
if !ok {
return nil, "", false
}
parent = next
}
return parent, parts[len(parts)-1], true
}
// addLowercaseSibling writes a <leaf>_lowercase sibling; no-op for non-strings.
func addLowercaseSibling(parent map[string]any, leaf string) {
switch v := parent[leaf].(type) {
case string:
parent[leaf+LowercaseSuffix] = strings.ToLower(v)
case []any:
out := make([]any, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, strings.ToLower(s))
}
}
parent[leaf+LowercaseSuffix] = out
case []string:
out := make([]string, len(v))
for i, s := range v {
out[i] = strings.ToLower(s)
}
parent[leaf+LowercaseSuffix] = out
}
}
@@ -0,0 +1,76 @@
package mapping
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PrepareForIndex casing", func() {
It("adds lowercased siblings for CaseInsensitive keyword and path fields", func() {
True := true
type doc struct {
Name string `json:"Name"`
Path string `json:"Path"`
Tags []string `json:"Tags"`
}
d := doc{Name: "Report FINAL", Path: "/Foo/Bar", Tags: []string{"Work", "Urgent"}}
m, err := PrepareForIndex(d, map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Path": {Type: TypePath, CaseInsensitive: &True},
"Tags": {CaseInsensitive: &True},
})
Expect(err).ToNot(HaveOccurred())
// Originals stay for the case-preserved base fields and the cascade.
Expect(m["Name"]).To(Equal("Report FINAL"))
Expect(m["Path"]).To(Equal("/Foo/Bar"))
Expect(m["Name_lowercase"]).To(Equal("report final"))
Expect(m["Path_lowercase"]).To(Equal("/foo/bar"))
Expect(m["Tags_lowercase"]).To(Equal([]any{"work", "urgent"}))
})
It("copies the value to a words sibling by default", func() {
type doc struct {
Name string `json:"Name"`
}
m, err := PrepareForIndex(doc{Name: "Report FINAL"}, nil)
Expect(err).ToNot(HaveOccurred())
// the analyzer splits and lowercases, the value goes over as is
Expect(m["Name"]).To(Equal("Report FINAL"))
Expect(m["Name_lowercase"]).To(Equal("report final"))
Expect(m["Name_words"]).To(Equal("Report FINAL"))
})
It("writes the lowercased sibling by default", func() {
type doc struct {
ID string `json:"ID"`
}
m, err := PrepareForIndex(doc{ID: "ABC"}, nil)
Expect(err).ToNot(HaveOccurred())
Expect(m["ID"+LowercaseSuffix]).To(Equal("abc"))
})
It("writes no sibling with CaseInsensitive off", func() {
False := false
type doc struct {
ID string `json:"ID"`
}
m, err := PrepareForIndex(doc{ID: "ABC"}, map[string]FieldOpts{"ID": {CaseInsensitive: &False}})
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(HaveKey("ID" + LowercaseSuffix))
})
It("writes an empty sibling for an empty array, like a non-empty one", func() {
True := true
type doc struct {
Tags []string `json:"Tags"`
}
m, err := PrepareForIndex(doc{Tags: []string{}}, map[string]FieldOpts{
"Tags": {CaseInsensitive: &True},
})
Expect(err).ToNot(HaveOccurred())
Expect(m).To(HaveKey("Tags" + LowercaseSuffix))
Expect(m["Tags"+LowercaseSuffix]).To(BeEmpty())
})
})
+180
View File
@@ -0,0 +1,180 @@
package mapping
import (
"fmt"
"reflect"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Deserialize builds a *T from bleve's flat hit.Fields map (json-tag keys,
// "parent.child" for nested pointers). Used to rebuild a search Resource from a
// hit. Fail-soft: an unparseable field stays at its zero value.
func Deserialize[T any](fields map[string]any) *T {
t := reflect.TypeFor[T]()
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("mapping: Deserialize requires a struct type, got %v", t))
}
out := reflect.New(t)
fillStruct(out.Elem(), fields, "", setValue)
return out.Interface().(*T)
}
// DeserializeAt is Deserialize scoped to a dotted prefix, used to rebuild one
// search-result facet (e.g. "audio"). Returns nil when nothing matched, so the
// caller can leave the enclosing pointer nil.
func DeserializeAt[T any](fields map[string]any, prefix string) *T {
t := reflect.TypeFor[T]()
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("mapping: DeserializeAt requires a struct type, got %v", t))
}
out := reflect.New(t)
if !fillStruct(out.Elem(), fields, prefix, setValue) {
return nil
}
return out.Interface().(*T)
}
// fillStruct walks v's exported fields, reading values from the flat fields map
// (json-tag keys, "parent.child" for nested pointers). setLeaf converts each raw
// value into a leaf, which is what lets the any-valued and string-valued
// deserializers share one walker. Returns true if any leaf was populated.
func fillStruct[V any](v reflect.Value, fields map[string]V, prefix string, setLeaf func(reflect.Value, V) error) bool {
t := v.Type()
touched := false
for i := 0; i < t.NumField(); i++ {
fi := resolveField(t.Field(i))
if fi.Skip {
continue
}
fv := v.Field(i)
if fi.Embedded {
// Embedded *struct: allocate, recurse, keep the pointer only if set.
if fv.Kind() == reflect.Ptr {
if fv.Type().Elem().Kind() != reflect.Struct || !fv.CanSet() {
continue
}
alloc := reflect.New(fv.Type().Elem())
if fillStruct(alloc.Elem(), fields, prefix, setLeaf) {
fv.Set(alloc)
touched = true
}
continue
}
if fv.Kind() == reflect.Struct && fillStruct(fv, fields, prefix, setLeaf) {
touched = true
}
continue
}
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
// Pointer to a nested (non-time) struct: recurse, keep the pointer only
// if a field was populated.
if fv.Kind() == reflect.Ptr {
if elem := fv.Type().Elem(); elem.Kind() == reflect.Struct && elem != timeType && elem != timestampType {
alloc := reflect.New(elem)
if fillStruct(alloc.Elem(), fields, key, setLeaf) {
fv.Set(alloc)
touched = true
}
continue
}
}
// Value nested struct (e.g. a tagged embedded struct): recurse under key.
if fv.Kind() == reflect.Struct && fv.Type() != timeType && fv.Type() != timestampType {
if fillStruct(fv, fields, key, setLeaf) {
touched = true
}
continue
}
if raw, ok := fields[key]; ok && setLeaf(fv, raw) == nil {
touched = true
}
}
return touched
}
// setValue writes raw (an any value from a bleve hit) into v, converting to the
// field's type. Returns an error on nil or a type mismatch.
func setValue(v reflect.Value, raw any) error {
if v.Kind() == reflect.Ptr {
alloc := reflect.New(v.Type().Elem())
if err := setValue(alloc.Elem(), raw); err != nil {
return err
}
v.Set(alloc)
return nil
}
if v.Type() == timeType || v.Type() == timestampType {
t, ok := parseTime(raw)
if !ok {
return fmt.Errorf("not an RFC3339 time: %v", raw)
}
setParsedTime(v, t)
return nil
}
if v.Kind() == reflect.Slice {
return setSlice(v, raw)
}
rv := reflect.ValueOf(raw)
if !rv.IsValid() {
return fmt.Errorf("nil value for %s", v.Type())
}
if !rv.Type().ConvertibleTo(v.Type()) {
return fmt.Errorf("cannot convert %s to %s", rv.Type(), v.Type())
}
v.Set(rv.Convert(v.Type()))
return nil
}
func setSlice(v reflect.Value, raw any) error {
items, ok := raw.([]any)
if !ok {
// bleve unwraps single-element slices; re-wrap here.
items = []any{raw}
}
// Compact in place with a single MakeSlice: unparseable elements are
// dropped, Slice(0, j) trims the tail.
out := reflect.MakeSlice(v.Type(), len(items), len(items))
j := 0
for _, item := range items {
if setValue(out.Index(j), item) == nil {
j++
}
}
if j == 0 {
return fmt.Errorf("no slice elements set from %T", raw)
}
v.Set(out.Slice(0, j))
return nil
}
func parseTime(raw any) (time.Time, bool) {
s, ok := raw.(string)
if !ok {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, false
}
return t, true
}
// setParsedTime writes t into v, which must be a time.Time or
// timestamppb.Timestamp field.
func setParsedTime(v reflect.Value, t time.Time) {
if v.Type() == timestampType {
v.Set(reflect.ValueOf(*timestamppb.New(t)))
return
}
v.Set(reflect.ValueOf(t))
}
@@ -0,0 +1,82 @@
package mapping
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// DeserializeStringsAt is DeserializeAt for a string-valued map (e.g. CS3
// ArbitraryMetadata), parsing each string into the field's Go type via strconv/
// time.Parse. Used to build a graph DriveItem facet. Returns nil when nothing
// under the prefix matched.
func DeserializeStringsAt[T any](fields map[string]string, prefix string) *T {
t := reflect.TypeFor[T]()
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("mapping: DeserializeStringsAt requires a struct type, got %v", t))
}
out := reflect.New(t)
// Callers pass a flat-key prefix with a trailing dot (e.g.
// "libre.graph.audio."); fillStruct joins segments with ".", so drop it.
if !fillStruct(out.Elem(), fields, strings.TrimSuffix(prefix, "."), setValueFromString) {
return nil
}
return out.Interface().(*T)
}
// setValueFromString parses the string raw into v's Go type via strconv/
// time.Parse, returning a descriptive error on failure.
func setValueFromString(v reflect.Value, raw string) error {
if v.Kind() == reflect.Ptr {
alloc := reflect.New(v.Type().Elem())
if err := setValueFromString(alloc.Elem(), raw); err != nil {
return err
}
v.Set(alloc)
return nil
}
if v.Type() == timeType || v.Type() == timestampType {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return fmt.Errorf("parse time %q: %w", raw, err)
}
setParsedTime(v, t)
return nil
}
switch v.Kind() {
case reflect.String:
v.SetString(raw)
return nil
case reflect.Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("parse bool %q: %w", raw, err)
}
v.SetBool(b)
return nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(raw, 10, v.Type().Bits())
if err != nil {
return fmt.Errorf("parse int %q: %w", raw, err)
}
v.SetInt(n)
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(raw, 10, v.Type().Bits())
if err != nil {
return fmt.Errorf("parse uint %q: %w", raw, err)
}
v.SetUint(n)
return nil
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(raw, v.Type().Bits())
if err != nil {
return fmt.Errorf("parse float %q: %w", raw, err)
}
v.SetFloat(f)
return nil
}
return fmt.Errorf("unsupported target kind %s", v.Kind())
}
@@ -0,0 +1,98 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/protobuf/types/known/timestamppb"
)
type stringFacet struct {
Artist *string `json:"artist,omitempty"`
Year *int32 `json:"year,omitempty"`
Duration *int64 `json:"duration,omitempty"`
Rating *float64 `json:"rating,omitempty"`
Explicit *bool `json:"explicit,omitempty"`
Taken *time.Time `json:"takenDateTime,omitempty"`
}
var _ = Describe("DeserializeStringsAt", func() {
It("errors on an unsupported target kind", func() {
v := reflect.New(reflect.TypeFor[[]int]()).Elem() // settable slice
Expect(setValueFromString(v, "x")).To(HaveOccurred(), "expected error for unsupported target kind (slice)")
})
It("parses basic types", func() {
r := DeserializeStringsAt[stringFacet](map[string]string{
"libre.graph.audio.artist": "Queen",
"libre.graph.audio.year": "1975",
"libre.graph.audio.duration": "354000",
"libre.graph.audio.rating": "4.9",
"libre.graph.audio.explicit": "true",
"libre.graph.audio.takenDateTime": "2024-01-02T03:04:05Z",
}, "libre.graph.audio.")
Expect(r).ToNot(BeNil())
Expect(r.Artist).ToNot(BeNil())
Expect(*r.Artist).To(Equal("Queen"))
Expect(r.Year).ToNot(BeNil())
Expect(*r.Year).To(Equal(int32(1975)))
Expect(r.Duration).ToNot(BeNil())
Expect(*r.Duration).To(Equal(int64(354000)))
Expect(r.Rating).ToNot(BeNil())
Expect(*r.Rating).To(Equal(4.9))
Expect(r.Explicit).ToNot(BeNil())
Expect(*r.Explicit).To(BeTrue())
Expect(r.Taken).ToNot(BeNil())
Expect(r.Taken.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))).To(BeTrue(), "Taken: %#v", r.Taken)
})
It("returns nil when nothing matches the prefix", func() {
r := DeserializeStringsAt[stringFacet](map[string]string{
"libre.graph.image.width": "1200",
}, "libre.graph.audio.")
Expect(r).To(BeNil())
})
It("parses into a timestamppb.Timestamp", func() {
type photoFacet struct {
Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"`
}
r := DeserializeStringsAt[photoFacet](map[string]string{
"libre.graph.photo.takenDateTime": "2024-05-06T07:08:09Z",
}, "libre.graph.photo.")
Expect(r).ToNot(BeNil())
Expect(r.Taken).ToNot(BeNil())
want := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)
Expect(r.Taken.AsTime().Equal(want)).To(BeTrue(), "Taken: got %v, want %v", r.Taken.AsTime(), want)
})
It("is fail-soft on malformed fields", func() {
// A single malformed field (year is unparseable as int) must not drop
// the whole facet. The bad field stays at zero value, the rest of the
// facet still populates. Mirrors the bleve-hit Deserialize behavior.
r := DeserializeStringsAt[stringFacet](map[string]string{
"libre.graph.audio.artist": "Iron Maiden",
"libre.graph.audio.year": "not-a-number",
"libre.graph.audio.duration": "354000",
"libre.graph.audio.explicit": "not-a-bool",
"libre.graph.audio.rating": "4.9",
}, "libre.graph.audio.")
Expect(r).ToNot(BeNil())
Expect(r.Artist).ToNot(BeNil())
Expect(*r.Artist).To(Equal("Iron Maiden"), "Artist should still be populated")
Expect(r.Duration).ToNot(BeNil())
Expect(*r.Duration).To(Equal(int64(354000)), "Duration should still be populated")
Expect(r.Rating).ToNot(BeNil())
Expect(*r.Rating).To(Equal(4.9), "Rating should still be populated")
Expect(r.Year).To(BeNil(), "Year should stay nil for bad int")
Expect(r.Explicit).To(BeNil(), "Explicit should stay nil for bad bool")
})
It("panics for a non-struct T", func() {
Expect(func() {
DeserializeStringsAt[int](nil, "")
}).To(Panic())
})
})
@@ -0,0 +1,139 @@
package mapping
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/protobuf/types/known/timestamppb"
)
type Leaf struct {
Name string `json:"Name"`
Size uint64 `json:"Size"`
Deleted bool `json:"Deleted"`
Tags []string `json:"Tags"`
Favorites []string `json:"Favorites"`
}
type audio struct {
Artist *string `json:"artist,omitempty"`
Year *int32 `json:"year,omitempty"`
}
type photo struct {
Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"`
Mtime *time.Time `json:"mtime,omitempty"`
}
type embedded struct {
Leaf
Audio *audio `json:"audio,omitempty"`
Photo *photo `json:"photo,omitempty"`
}
// taggedEmbedded embeds Leaf with a json tag, so encoding/json nests it under
// "leaf" rather than flattening it onto the parent.
type taggedEmbedded struct {
Leaf `json:"leaf"`
Top string `json:"top"`
}
var _ = Describe("Deserialize", func() {
It("panics for a non-struct type at a prefix", func() {
Expect(func() {
_ = DeserializeAt[int](map[string]any{}, "")
}).To(Panic())
})
It("panics for a non-struct T", func() {
Expect(func() {
Deserialize[int](nil)
}).To(Panic())
})
It("deserializes leaf fields", func() {
r := Deserialize[Leaf](map[string]any{
"Name": "n",
"Size": float64(42),
"Deleted": true,
})
Expect(r.Name).To(Equal("n"))
Expect(r.Size).To(Equal(uint64(42)))
Expect(r.Deleted).To(BeTrue())
})
It("coerces a scalar into a slice field", func() {
r := Deserialize[Leaf](map[string]any{
"Tags": "single",
"Favorites": []any{"a", "b"},
})
Expect(r.Tags).To(Equal([]string{"single"}))
Expect(r.Favorites).To(Equal([]string{"a", "b"}))
})
It("parses timestamps", func() {
r := Deserialize[embedded](map[string]any{
"photo.takenDateTime": "2024-01-02T03:04:05Z",
"photo.mtime": "2024-05-06T07:08:09Z",
})
Expect(r.Photo).ToNot(BeNil())
Expect(r.Photo.Taken).ToNot(BeNil())
expected := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
Expect(r.Photo.Taken.AsTime().Equal(expected)).To(BeTrue(), "Taken: got %v, want %v", r.Photo.Taken.AsTime(), expected)
Expect(r.Photo.Mtime).ToNot(BeNil())
Expect(r.Photo.Mtime.Equal(time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC))).To(BeTrue(), "Mtime: %v", r.Photo.Mtime)
})
It("is fail-soft on malformed values", func() {
// Malformed values (type mismatch, unparseable time) leave the
// affected field at its zero value instead of dropping the whole
// record. Matches the pre-refactor getFieldValue behavior so
// matchToResource never returns nil on a corrupted hit.
r := Deserialize[embedded](map[string]any{
"Name": "n",
"Size": "not-a-number", // wrong type
"Deleted": true,
"photo.takenDateTime": "not-an-rfc3339-time",
"photo.mtime": "2024-05-06T07:08:09Z",
})
Expect(r).ToNot(BeNil())
Expect(r.Name).To(Equal("n"))
Expect(r.Size).To(Equal(uint64(0)), "Size should stay zero on mismatch")
Expect(r.Deleted).To(BeTrue())
Expect(r.Photo).ToNot(BeNil(), "Photo should be populated because Mtime parsed ok")
Expect(r.Photo.Taken).To(BeNil(), "Taken should stay nil for unparseable time")
Expect(r.Photo.Mtime).ToNot(BeNil(), "Mtime should be parsed")
})
It("returns nil when nothing matches the prefix", func() {
r := DeserializeAt[audio](map[string]any{"Name": "n"}, "audio")
Expect(r).To(BeNil())
})
It("returns a value when the prefix matches", func() {
r := DeserializeAt[audio](map[string]any{
"audio.artist": "A",
"audio.year": float64(2024), // setValue: pointer + numeric convert
}, "audio")
Expect(r).ToNot(BeNil())
Expect(r.Artist).ToNot(BeNil())
Expect(*r.Artist).To(Equal("A"))
Expect(r.Year).ToNot(BeNil())
Expect(*r.Year).To(Equal(int32(2024)))
})
It("nests a tagged embedded struct instead of flattening it", func() {
// matches encoding/json: a tagged embedded struct is read under its tag.
r := Deserialize[taggedEmbedded](map[string]any{
"leaf.Name": "n",
"top": "t",
})
Expect(r.Leaf.Name).To(Equal("n"))
Expect(r.Top).To(Equal("t"))
// the flattened top-level key must NOT populate the nested field.
flat := Deserialize[taggedEmbedded](map[string]any{"Name": "flat"})
Expect(flat.Leaf.Name).To(BeEmpty())
})
})
+36
View File
@@ -0,0 +1,36 @@
package mapping
import (
"reflect"
"strings"
)
// FieldNameIndex maps a lowercased field path to the real field name for every
// field of t, recursing into nested facets (photo.cameraMake, ...). Names come
// from json tags, so it is backend-neutral; the query layer resolves KQL keys
// case-insensitively against it.
func FieldNameIndex(t reflect.Type, overrides map[string]FieldOpts) map[string]string {
out := map[string]string{}
var walk func(t reflect.Type, prefix string)
walk = func(t reflect.Type, prefix string) {
_ = walkFields(t, func(fi fieldInfo) error {
name := fi.Name
if prefix != "" {
name = prefix + "." + fi.Name
}
out[strings.ToLower(name)] = name
fieldType := overrides[name].Type
if fieldType == "" {
fieldType = inferType(fi.GoField.Type)
}
// recurse into nested facets; time.Time is a struct too but a leaf.
if sub := structType(fi.GoField.Type); sub != nil && fieldType != TypeDatetime {
walk(sub, name)
}
return nil
})
}
walk(t, "")
return out
}
@@ -0,0 +1,101 @@
package mapping_test
import (
"reflect"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func resourceFieldIndex() map[string]string {
return mapping.FieldNameIndex(
reflect.TypeFor[search.Resource](),
search.Resource{}.SearchFieldOverrides(),
)
}
// resolve looks up the lowercased key in the index, falling back to the key.
func resolve(idx map[string]string, key string) string {
if v, ok := idx[strings.ToLower(key)]; ok {
return v
}
return key
}
// NestInner is embedded with a json tag below, so it must nest, not flatten.
type NestInner struct {
A string `json:"A"`
}
type taggedOuter struct {
NestInner `json:"inner"`
Top string `json:"top"`
}
var _ = Describe("FieldNameIndex", func() {
It("resolves top-level fields case-insensitively", func() {
idx := resourceFieldIndex()
for in, want := range map[string]string{
"rootid": "RootID", "ROOTID": "RootID", "RootID": "RootID",
"name": "Name", "NAME": "Name",
"mimetype": "MimeType", "MimeType": "MimeType",
"tags": "Tags", "favorites": "Favorites",
"mtime": "Mtime", "parentid": "ParentID", "id": "ID",
} {
Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in)
}
})
// Facet sub-fields are lowerCamelCase in the index (from libregraph json
// tags); the derived index resolves them case-insensitively.
It("resolves facet sub-fields case-insensitively", func() {
idx := resourceFieldIndex()
for in, want := range map[string]string{
// case-insensitive: same field, different casings
"photo.cameramake": "photo.cameraMake",
"photo.CAMERAMAKE": "photo.cameraMake",
// a representative sub-field across each facet
"photo.takendatetime": "photo.takenDateTime",
"audio.artist": "audio.artist",
"audio.albumartist": "audio.albumArtist",
"image.width": "image.width",
"location.latitude": "location.latitude",
} {
Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in)
}
})
It("passes unknown keys through unchanged", func() {
idx := resourceFieldIndex()
Expect(resolve(idx, "nope.field")).To(Equal("nope.field"))
Expect(resolve(idx, "custom")).To(Equal("custom"))
})
// All top-level fields are covered from one derived source, so both
// backends resolve them the same way.
It("covers all top-level fields", func() {
idx := resourceFieldIndex()
for in, want := range map[string]string{
"rootid": "RootID", "path": "Path", "id": "ID", "name": "Name",
"size": "Size", "mtime": "Mtime", "type": "Type",
"content": "Content", "hidden": "Hidden", "tags": "Tags",
"favorites": "Favorites",
} {
Expect(resolve(idx, in)).To(Equal(want), "derived should cover %q", in)
}
})
// A json-tagged embedded struct nests under its tag in the derived index
// too (walkFields must match encoding/json), so its fields are "inner.A",
// not "A".
It("nests json-tagged embedded structs", func() {
idx := mapping.FieldNameIndex(reflect.TypeFor[taggedOuter](), nil)
Expect(resolve(idx, "inner.a")).To(Equal("inner.A")) // nested under the tag
Expect(resolve(idx, "top")).To(Equal("top"))
Expect(resolve(idx, "a")).To(Equal("a")) // not flattened: bare "a" is not a key
})
})
@@ -0,0 +1,106 @@
package mapping
import (
"errors"
"reflect"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The walker is shared by both deserializers, so its structural behavior
// (flattening embedded structs, recursing into nested pointers, joining the
// prefix, keeping a pointer only when a field was set, fail-soft skipping) is
// tested here once against a trivial string setter instead of twice through
// Deserialize and DeserializeStringsAt.
// FsEmbVal/FsEmbPtr are exported so their embedded field is exported; an
// unexported embedded type would be skipped by resolveField.
type FsEmbVal struct {
EV string `json:"ev"`
}
type FsEmbPtr struct {
EP string `json:"ep"`
}
type fsNested struct {
N string `json:"n"`
}
type fsRoot struct {
FsEmbVal // embedded value struct: fields promoted
*FsEmbPtr // embedded pointer struct: allocated on demand
Leaf string `json:"leaf"`
Nested *fsNested `json:"nested"` // nested pointer: recursed under "nested."
}
var errBadLeaf = errors.New("bad leaf")
// fsSet writes raw into a string field; the "BAD" sentinel simulates a parse
// failure so the fail-soft path can be exercised.
func fsSet(v reflect.Value, raw string) error {
if raw == "BAD" {
return errBadLeaf
}
v.SetString(raw)
return nil
}
var _ = Describe("fillStruct", func() {
fill := func(fields map[string]string, prefix string) (fsRoot, bool) {
var root fsRoot
touched := fillStruct(reflect.ValueOf(&root).Elem(), fields, prefix, fsSet)
return root, touched
}
It("flattens embedded and recurses nested", func() {
root, touched := fill(map[string]string{
"leaf": "L",
"ev": "EV",
"ep": "EP",
"nested.n": "N",
}, "")
Expect(touched).To(BeTrue())
Expect(root.Leaf).To(Equal("L"))
Expect(root.EV).To(Equal("EV"), "embedded value not promoted")
Expect(root.FsEmbPtr).ToNot(BeNil(), "embedded pointer not allocated")
Expect(root.EP).To(Equal("EP"))
Expect(root.Nested).ToNot(BeNil(), "nested pointer not populated")
Expect(root.Nested.N).To(Equal("N"))
})
It("leaves touched false and pointers nil when nothing matches", func() {
root, touched := fill(map[string]string{"other": "x"}, "")
Expect(touched).To(BeFalse())
Expect(root.Nested).To(BeNil(), "Nested should stay nil")
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil")
})
It("joins the prefix arg with the field name", func() {
root, touched := fill(map[string]string{
"pre.leaf": "L",
"pre.nested.n": "N",
}, "pre")
Expect(touched).To(BeTrue())
Expect(root.Leaf).To(Equal("L"))
Expect(root.Nested).ToNot(BeNil())
Expect(root.Nested.N).To(Equal("N"))
})
It("is fail-soft: errored leaf stays zero, walk continues", func() {
root, touched := fill(map[string]string{
"leaf": "BAD",
"ev": "EV",
}, "")
Expect(touched).To(BeTrue(), "expected touched because ev was set")
Expect(root.Leaf).To(BeEmpty(), "errored leaf should stay zero")
Expect(root.EV).To(Equal("EV"), "walk should continue past the error")
})
It("drops the embedded pointer when its only field errors", func() {
root, touched := fill(map[string]string{"ep": "BAD"}, "")
Expect(touched).To(BeFalse())
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil on error")
})
})
+51
View File
@@ -0,0 +1,51 @@
package mapping
import "strings"
// GeopointSuffix is appended to a field's name to produce the sibling key
// that carries the geo_point / bleve-geopoint representation of the
// original facet. For example, a libregraph "location" object with
// longitude / latitude / altitude is preserved as-is under "location" (for
// data retrieval and numeric queries) while "location_geopoint" carries
// the {lat, lon} form the geo indices understand.
const GeopointSuffix = "_geopoint"
// addGeopointSiblings walks the overrides; for each TypeGeopoint entry at
// a dotted path (e.g. "location" or "journey.start") it writes a sibling
// under the suffixed key with the {lat, lon} form both bleve's
// ExtractGeoPoint and OpenSearch's geo_point parser accept. The original
// facet object stays untouched so downstream code still sees the full
// libregraph shape (including altitude).
func addGeopointSiblings(m map[string]any, overrides map[string]FieldOpts) {
for key, opts := range overrides {
if opts.Type == TypeGeopoint {
addGeopointSibling(m, key)
}
}
}
// addGeopointSibling resolves dottedPath within m and, if the target is a
// libregraph-shaped geo object (with numeric "longitude" and "latitude"),
// writes the `{lat, lon}` sibling at the same level under the suffixed key.
func addGeopointSibling(m map[string]any, dottedPath string) {
parts := strings.Split(dottedPath, ".")
parent := m
for _, p := range parts[:len(parts)-1] {
next, ok := parent[p].(map[string]any)
if !ok {
return
}
parent = next
}
leaf := parts[len(parts)-1]
obj, ok := parent[leaf].(map[string]any)
if !ok {
return
}
lon, hasLon := obj["longitude"].(float64)
lat, hasLat := obj["latitude"].(float64)
if !hasLon || !hasLat {
return
}
parent[leaf+GeopointSuffix] = map[string]any{"lat": lat, "lon": lon}
}
+143
View File
@@ -0,0 +1,143 @@
package mapping
import (
"reflect"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("BuildMapping geopoint errors", func() {
// build-mapping error paths: an override that doesn't fit the Go field.
It("errors for geopoint on a non-struct field on both backends", func() {
type doc struct {
Name string `json:"name"`
}
// Geopoint on a non-struct field must error on both backends.
_, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}})
Expect(err).To(HaveOccurred(), "bleve: expected error for geopoint on string field")
_, err = OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}})
Expect(err).To(HaveOccurred(), "opensearch: expected error for geopoint on string field")
})
})
var _ = Describe("addGeopointSibling", func() {
It("bails without panicking when the intermediate is missing", func() {
m := map[string]any{"journey": "not-a-map"}
addGeopointSibling(m, "journey.start") // must bail, not panic
Expect(m).ToNot(HaveKey("journey.start" + GeopointSuffix))
})
})
var _ = Describe("PrepareForIndex geopoint", func() {
It("adds a geopoint sibling", func() {
type geoDoc struct {
Location *struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Altitude *float64 `json:"altitude,omitempty"`
} `json:"location,omitempty"`
}
lon, lat, alt := 11.1, 49.4, 1047.7
doc := geoDoc{Location: &struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Altitude *float64 `json:"altitude,omitempty"`
}{Longitude: &lon, Latitude: &lat, Altitude: &alt}}
m, err := PrepareForIndex(doc, map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Original location object stays untouched (full libregraph shape).
orig, ok := m["location"].(map[string]any)
Expect(ok).To(BeTrue(), "expected location object preserved, got %T", m["location"])
Expect(orig["longitude"]).To(Equal(lon))
Expect(orig["latitude"]).To(Equal(lat))
Expect(orig["altitude"]).To(Equal(alt))
// Sibling location_geopoint has {lat, lon} for the geo indices.
gp, ok := m["location"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "expected location_geopoint sibling, got %T", m["location"+GeopointSuffix])
Expect(gp["lat"]).To(Equal(lat))
Expect(gp["lon"]).To(Equal(lon))
})
It("skips incomplete geopoints", func() {
type geoDoc struct {
Location *struct {
Altitude *float64 `json:"altitude,omitempty"`
} `json:"location,omitempty"`
}
alt := 100.0
doc := geoDoc{Location: &struct {
Altitude *float64 `json:"altitude,omitempty"`
}{Altitude: &alt}}
m, err := PrepareForIndex(doc, map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Original stays (altitude alone is still useful metadata).
Expect(m).To(HaveKey("location"), "location should still be present when only altitude is set")
// No sibling without both lon and lat.
Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected")
})
It("writes no sibling without the geopoint override", func() {
type geoDoc struct {
Location *struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
} `json:"location,omitempty"`
}
lon, lat := 11.1, 49.4
doc := geoDoc{Location: &struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
}{Longitude: &lon, Latitude: &lat}}
m, err := PrepareForIndex(doc, nil)
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected without override")
})
It("handles nested geopoints via the dotted-path walker", func() {
// journey.start and journey.end - two geopoints in the same facet,
// demonstrating the dotted-path walker.
type geo struct {
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
}
type journey struct {
Start *geo `json:"start,omitempty"`
End *geo `json:"end,omitempty"`
}
type doc struct {
Journey *journey `json:"journey,omitempty"`
}
slon, slat := 11.0, 49.0
elon, elat := 13.4, 52.5
d := doc{Journey: &journey{
Start: &geo{Longitude: &slon, Latitude: &slat},
End: &geo{Longitude: &elon, Latitude: &elat},
}}
m, err := PrepareForIndex(d, map[string]FieldOpts{
"journey.start": {Type: TypeGeopoint},
"journey.end": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
j, ok := m["journey"].(map[string]any)
Expect(ok).To(BeTrue(), "journey not an object: %T", m["journey"])
startGp, ok := j["start"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "journey.start sibling: %#v", j["start"+GeopointSuffix])
Expect(startGp["lat"]).To(Equal(slat))
Expect(startGp["lon"]).To(Equal(slon))
endGp, ok := j["end"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "journey.end sibling: %#v", j["end"+GeopointSuffix])
Expect(endGp["lat"]).To(Equal(elat))
Expect(endGp["lon"]).To(Equal(elon))
})
})
+121
View File
@@ -0,0 +1,121 @@
package mapping
import (
"reflect"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
)
var (
timeType = reflect.TypeFor[time.Time]()
timestampType = reflect.TypeFor[timestamppb.Timestamp]()
)
// deref unwraps pointer and slice types to their element type.
func deref(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem()
}
return t
}
// inferType returns the mapping type for a Go type. Pointers and slices are
// unwrapped to their element type. time.Time and timestamppb.Timestamp become
// datetime; other structs become object.
func inferType(t reflect.Type) string {
t = deref(t)
switch t.Kind() {
case reflect.String:
return TypeKeyword
case reflect.Bool:
return TypeBool
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return TypeNumeric
case reflect.Struct:
if t == timeType || t == timestampType {
return TypeDatetime
}
return TypeObject
}
return ""
}
// fieldInfo is the resolved metadata for one struct field.
type fieldInfo struct {
Name string
GoField reflect.StructField
Skip bool
Embedded bool
}
// resolveField resolves a struct field's json-tag name and skip/embed state.
func resolveField(sf reflect.StructField) fieldInfo {
if !sf.IsExported() {
return fieldInfo{Skip: true}
}
name := sf.Name
named := false
tag := sf.Tag.Get("json")
if tag != "" {
first, _, _ := strings.Cut(tag, ",")
if first == "-" {
return fieldInfo{Skip: true}
}
if first != "" {
name = first
named = true
}
}
return fieldInfo{
Name: name,
GoField: sf,
// An anonymous field is embedded (flattened onto the parent) only when it
// has no json tag name, matching encoding/json: a tag name nests it as a
// regular field instead.
Embedded: sf.Anonymous && !named,
}
}
// walkFields visits exported leaf fields of t, flattening embedded structs
// onto the enclosing level. It returns the first error returned by fn.
func walkFields(t reflect.Type, fn func(fi fieldInfo) error) error {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
for i := 0; i < t.NumField(); i++ {
fi := resolveField(t.Field(i))
if fi.Skip {
continue
}
if fi.Embedded {
if err := walkFields(fi.GoField.Type, fn); err != nil {
return err
}
continue
}
if err := fn(fi); err != nil {
return err
}
}
return nil
}
// structType returns the underlying struct type, unwrapping pointers and
// slices. Returns nil when t is not a walkable struct (e.g. time.Time).
func structType(t reflect.Type) reflect.Type {
t = deref(t)
if t.Kind() != reflect.Struct {
return nil
}
if t == timeType || t == timestampType {
return nil
}
return t
}
+100
View File
@@ -0,0 +1,100 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/protobuf/types/known/timestamppb"
)
var _ = Describe("inferType", func() {
It("returns empty for unsupported kinds", func() {
Expect(inferType(reflect.TypeFor[map[string]int]())).To(BeEmpty(), "map")
Expect(inferType(reflect.TypeFor[chan int]())).To(BeEmpty(), "chan")
})
DescribeTable("infers the mapping type",
func(in any, want string) {
Expect(inferType(reflect.TypeOf(in))).To(Equal(want))
},
Entry("string", "", TypeKeyword),
Entry("*string", (*string)(nil), TypeKeyword),
Entry("[]string", []string(nil), TypeKeyword),
Entry("bool", false, TypeBool),
Entry("int", int(0), TypeNumeric),
Entry("int64", int64(0), TypeNumeric),
Entry("uint64", uint64(0), TypeNumeric),
Entry("float64", float64(0), TypeNumeric),
Entry("time.Time", time.Time{}, TypeDatetime),
Entry("*time.Time", (*time.Time)(nil), TypeDatetime),
Entry("*timestamppb.Timestamp", (*timestamppb.Timestamp)(nil), TypeDatetime),
Entry("struct", struct{ X int }{}, TypeObject),
Entry("*struct", (*struct{ X int })(nil), TypeObject),
)
})
var _ = Describe("resolveField", func() {
type S struct {
Exported string `json:"exp"`
Renamed string `json:"renamed,omitempty"`
NoTag string
OmitOnly string `json:",omitempty"`
Skipped string `json:"-"`
unexported string //nolint:unused
}
st := reflect.TypeFor[S]()
DescribeTable("resolves the field name and skip flag",
func(fieldIdx int, wantName string, wantSkip bool) {
fi := resolveField(st.Field(fieldIdx))
Expect(fi.Skip).To(Equal(wantSkip), "field %d skip", fieldIdx)
if !wantSkip {
Expect(fi.Name).To(Equal(wantName), "field %d name", fieldIdx)
}
},
Entry("exported json tag", 0, "exp", false),
Entry("renamed with omitempty", 1, "renamed", false),
Entry("no tag", 2, "NoTag", false),
Entry("omitempty only", 3, "OmitOnly", false),
Entry("json:- skipped", 4, "", true),
Entry("unexported skipped", 5, "", true),
)
})
var _ = Describe("walkFields", func() {
It("flattens embedded structs", func() {
type Inner struct {
A string `json:"a"`
B int `json:"b"`
}
type Outer struct {
Inner
C bool `json:"c"`
}
var names []string
err := walkFields(reflect.TypeFor[Outer](), func(fi fieldInfo) error {
names = append(names, fi.Name)
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(names).To(Equal([]string{"a", "b", "c"}))
})
})
var _ = Describe("structType", func() {
type S struct{ X int }
DescribeTable("resolves struct-ish types",
func(in reflect.Type, wantNil bool) {
got := structType(in)
Expect(got == nil).To(Equal(wantNil))
},
Entry("struct", reflect.TypeFor[S](), false),
Entry("*struct", reflect.TypeFor[*S](), false),
Entry("[]struct", reflect.TypeFor[[]S](), false),
Entry("time.Time", reflect.TypeFor[time.Time](), true),
Entry("string", reflect.TypeFor[string](), true),
)
})
@@ -0,0 +1,13 @@
package mapping
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMapping(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Mapping Suite")
}
+136
View File
@@ -0,0 +1,136 @@
package mapping
import (
"fmt"
"maps"
"reflect"
)
// OpenSearchBuildMapping builds the OpenSearch "properties" map (the value
// of mappings.properties) for type t by walking the struct via reflection.
// Field names come from json tags; overrides are keyed by those names.
//
// The returned map contains plain JSON-friendly values (strings, bools,
// nested maps) and can be marshalled directly.
func OpenSearchBuildMapping(t reflect.Type, overrides map[string]FieldOpts) (map[string]any, error) {
return buildOpenSearchProperties(t, overrides, "")
}
func buildOpenSearchProperties(t reflect.Type, overrides map[string]FieldOpts, prefix string) (map[string]any, error) {
props := map[string]any{}
err := walkFields(t, func(fi fieldInfo) error {
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
opts := overrides[key]
fieldType := opts.Type
if fieldType == "" {
fieldType = inferType(fi.GoField.Type)
}
if fieldType == TypeObject {
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: object type on non-struct field %q", key)
}
subProps, err := buildOpenSearchProperties(sub, overrides, key)
if err != nil {
return err
}
props[fi.Name] = map[string]any{"properties": subProps}
return nil
}
if fieldType == TypeGeopoint {
// Keep the facet object, add a sibling _geopoint field (see GeopointSuffix).
sub := structType(fi.GoField.Type)
if sub == nil {
return fmt.Errorf("mapping: geopoint type on non-struct field %q", key)
}
subProps, err := buildOpenSearchProperties(sub, overrides, key)
if err != nil {
return err
}
props[fi.Name] = map[string]any{"properties": subProps}
props[fi.Name+GeopointSuffix] = map[string]any{"type": "geo_point"}
return nil
}
if fieldType == TypeKeyword || fieldType == TypePath {
// path_hierarchy is case-preserving here; casing lives in the value.
m := map[string]any{"type": "keyword"}
if fieldType == TypePath {
m = map[string]any{"type": "text", "analyzer": "path_hierarchy"}
}
props[fi.Name] = m
if opts.caseInsensitive() {
// search-only, like the bleve sibling: never sorted or
// aggregated on, so no doc_values
sibling := maps.Clone(m)
if sibling["type"] == "keyword" {
sibling["doc_values"] = false
}
props[fi.Name+LowercaseSuffix] = sibling
}
if fieldType == TypeKeyword && opts.wordBroken() {
props[fi.Name+WordsSuffix] = map[string]any{"type": "text", "analyzer": WordsAnalyzer}
}
return nil
}
fm, err := openSearchFieldMapping(fieldType, fi.GoField.Type)
if err != nil {
return fmt.Errorf("mapping: field %q: %w", key, err)
}
props[fi.Name] = fm
return nil
})
return props, err
}
// openSearchFieldMapping handles the non-keyword/path types; keyword and path
// are emitted (with their cased forms) by buildOpenSearchProperties directly.
func openSearchFieldMapping(fieldType string, goType reflect.Type) (map[string]any, error) {
switch fieldType {
case TypeFulltext:
return map[string]any{
"type": "text",
"term_vector": "with_positions_offsets",
"analyzer": WordsAnalyzer,
}, nil
case TypeWildcard:
// OpenSearch stores wildcard fields with doc_values=false by
// default, so emit it explicitly to keep local and remote
// mappings in sync for the Apply comparison.
return map[string]any{"type": "wildcard", "doc_values": false}, nil
case TypeNumeric:
return map[string]any{"type": openSearchNumericType(goType)}, nil
case TypeBool:
return map[string]any{"type": "boolean"}, nil
case TypeDatetime:
return map[string]any{"type": "date"}, nil
case TypeGeopoint:
return map[string]any{"type": "geo_point"}, nil
case "":
return nil, fmt.Errorf("no type inferred and no override")
}
return nil, fmt.Errorf("unsupported type %q", fieldType)
}
// openSearchNumericType maps a Go numeric type to an OpenSearch numeric
// field type.
func openSearchNumericType(t reflect.Type) string {
t = deref(t)
switch t.Kind() {
case reflect.Float32:
return "float"
case reflect.Float64:
return "double"
case reflect.Int8, reflect.Uint8, reflect.Int16, reflect.Uint16:
return "short"
case reflect.Int32, reflect.Uint32:
return "integer"
}
return "long"
}
@@ -0,0 +1,161 @@
package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type osDoc struct {
ID string `json:"ID"`
Size uint64 `json:"Size"`
Deleted bool `json:"Deleted"`
CreatedAt time.Time `json:"CreatedAt"`
Rating float64 `json:"Rating"`
Nested *struct {
Artist string `json:"artist"`
Year int32 `json:"year"`
} `json:"nested,omitempty"`
}
var _ = Describe("OpenSearchBuildMapping", func() {
DescribeTable("maps numeric Go types to OpenSearch types",
func(field, wantType string) {
type doc struct {
A int8 `json:"a"`
B int16 `json:"b"`
C int32 `json:"c"`
D int64 `json:"d"`
E uint8 `json:"e"`
F uint64 `json:"f"`
G float32 `json:"g"`
H float64 `json:"h"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), nil)
Expect(err).ToNot(HaveOccurred())
Expect(props[field].(map[string]any)["type"]).To(Equal(wantType), "%s type", field)
},
Entry("int8 -> short", "a", "short"),
Entry("int16 -> short", "b", "short"),
Entry("int32 -> integer", "c", "integer"),
Entry("int64 -> long", "d", "long"),
Entry("uint8 -> short", "e", "short"),
Entry("uint64 -> long", "f", "long"),
Entry("float32 -> float", "g", "float"),
Entry("float64 -> double", "h", "double"),
)
DescribeTable("infers field types",
func(field, wantType string) {
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
Expect(err).ToNot(HaveOccurred())
m, ok := props[field].(map[string]any)
Expect(ok).To(BeTrue(), "%s: missing or not a map: %#v", field, props[field])
Expect(m["type"]).To(Equal(wantType), "%s type", field)
},
Entry("ID -> keyword", "ID", "keyword"),
Entry("Size -> long", "Size", "long"),
Entry("Deleted -> boolean", "Deleted", "boolean"),
Entry("CreatedAt -> date", "CreatedAt", "date"),
Entry("Rating -> double", "Rating", "double"),
)
It("maps nested structs with their sub-properties", func() {
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
Expect(err).ToNot(HaveOccurred())
nested, ok := props["nested"].(map[string]any)
Expect(ok).To(BeTrue(), "nested: not a map: %#v", props["nested"])
sub, ok := nested["properties"].(map[string]any)
Expect(ok).To(BeTrue(), "nested.properties: missing: %#v", nested)
artist, ok := sub["artist"].(map[string]any)
Expect(ok).To(BeTrue(), "nested.artist: %#v", sub)
Expect(artist["type"]).To(Equal("keyword"), "nested.artist.type")
year, ok := sub["year"].(map[string]any)
Expect(ok).To(BeTrue(), "nested.year: %#v", sub)
Expect(year["type"]).To(Equal("integer"), "nested.year.type (int32 -> integer expected)")
})
It("applies field overrides", func() {
True := true
type doc struct {
Name string `json:"Name"`
Content string `json:"Content"`
Path string `json:"Path"`
MimeType string `json:"MimeType"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Content": {Type: TypeFulltext},
"Path": {Type: TypePath, CaseInsensitive: &True},
"MimeType": {Type: TypeWildcard},
})
Expect(err).ToNot(HaveOccurred())
// Name: case-preserved keyword base + lowercased search-only sibling.
Expect(props["Name"]).To(Equal(map[string]any{"type": "keyword"}))
Expect(props["Name_lowercase"]).To(Equal(map[string]any{"type": "keyword", "doc_values": false}))
content := props["Content"].(map[string]any)
Expect(content["type"]).To(Equal("text"), "Content: %#v", content)
Expect(content["term_vector"]).To(Equal("with_positions_offsets"), "Content: %#v", content)
Expect(content["analyzer"]).To(Equal(WordsAnalyzer), "Content uses the words analyzer, like bleve")
// Path: path_hierarchy base + lowercased sibling, both case-preserving.
Expect(props["Path"]).To(Equal(map[string]any{"type": "text", "analyzer": "path_hierarchy"}))
Expect(props["Path_lowercase"]).To(Equal(map[string]any{"type": "text", "analyzer": "path_hierarchy"}))
mime := props["MimeType"].(map[string]any)
Expect(mime["type"]).To(Equal("wildcard"), "MimeType: %#v", mime)
})
It("gives a keyword its lowercase and words siblings by default", func() {
type doc struct {
Name string `json:"Name"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), nil)
Expect(err).ToNot(HaveOccurred())
// the base stays a keyword, the words go to their own sibling
Expect(props["Name"]).To(Equal(map[string]any{"type": "keyword"}))
Expect(props["Name_lowercase"]).To(Equal(map[string]any{"type": "keyword", "doc_values": false}))
Expect(props["Name_words"]).To(Equal(map[string]any{"type": "text", "analyzer": WordsAnalyzer}))
})
It("leaves a keyword one whole value with NoWordBreaker", func() {
True := true
type doc struct {
Tag string `json:"Tag"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
"Tag": {NoWordBreaker: &True},
})
Expect(err).ToNot(HaveOccurred())
Expect(props).To(HaveKey("Tag_lowercase"))
Expect(props).ToNot(HaveKey("Tag_words"))
})
It("builds an object plus a geo_point sibling for geopoints", func() {
type doc struct {
Location *struct {
Lon float64 `json:"longitude"`
Lat float64 `json:"latitude"`
Alt float64 `json:"altitude"`
} `json:"location,omitempty"`
}
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Object for libregraph-shape data retrieval.
loc, ok := props["location"].(map[string]any)
Expect(ok).To(BeTrue(), "location: %#v", props["location"])
sub, ok := loc["properties"].(map[string]any)
Expect(ok).To(BeTrue(), "location should have numeric sub-properties, got %#v", loc)
for _, k := range []string{"longitude", "latitude", "altitude"} {
prop, ok := sub[k].(map[string]any)
Expect(ok).To(BeTrue(), "location.%s: %#v", k, sub[k])
Expect(prop["type"]).To(Equal("double"), "location.%s: %#v", k, sub[k])
}
// Sibling geo_point for spatial queries.
gp, ok := props["location"+GeopointSuffix].(map[string]any)
Expect(ok).To(BeTrue(), "location%s: %#v", GeopointSuffix, props["location"+GeopointSuffix])
Expect(gp["type"]).To(Equal("geo_point"), "location%s.type", GeopointSuffix)
})
})
+57
View File
@@ -0,0 +1,57 @@
// Package mapping builds search index mappings for bleve and OpenSearch from
// a Go struct via reflection. Field names come from json tags; the caller
// provides overrides for fields that need a specific type or analyzer.
package mapping
// Field type constants used in FieldOpts.Type. An empty Type means the type
// is inferred from the Go field via reflection.
const (
TypeKeyword = "keyword"
TypeFulltext = "fulltext"
TypePath = "path"
TypeWildcard = "wildcard"
TypeNumeric = "numeric"
TypeDatetime = "datetime"
TypeBool = "bool"
TypeObject = "object"
TypeGeopoint = "geopoint"
)
// LowercaseSuffix names the lowercased sibling of a keyword/path field.
const LowercaseSuffix = "_lowercase"
// WordsSuffix names the word-broken sibling of a keyword field.
const WordsSuffix = "_words"
// WordsAnalyzer names the analyzer both engines register for the words sibling.
const WordsAnalyzer = "words"
// FieldOpts overrides the default type inference for a struct field. Keys in
// the override map are json-tag names (e.g. "Name", "location", "audio.artist"),
// not Go field names.
type FieldOpts struct {
// Type is one of the Type* constants. Empty means "infer from Go type".
Type string
// CaseInsensitive additionally indexes a lowercased <name>_lowercase sibling
// for case-insensitive search; the case-preserved base is always indexed.
// On by default for keyword/path fields, KQL searches case-insensitively;
// false opts a field out (ids, paths).
CaseInsensitive *bool
// NoWordBreaker is SharePoint's switch, with its default: a keyword field
// additionally indexes a <name>_words sibling split into lowercased words
// (no stemming), so a single word matches a value that contains it,
// "report" finds "Report.txt"; true opts a field out and leaves it one
// whole value (tags, ids, paths). The base stays the whole value for
// returning and aggregating; wildcards and whole-value matches use the
// _lowercase sibling. Keyword only.
NoWordBreaker *bool
// IncludeInAll controls bleve's _all field inclusion. Nil means "use the
// bleve default for this field type". Has no effect on OpenSearch.
IncludeInAll *bool
}
func (o FieldOpts) caseInsensitive() bool { return o.CaseInsensitive == nil || *o.CaseInsensitive }
func (o FieldOpts) wordBroken() bool { return o.NoWordBreaker == nil || !*o.NoWordBreaker }
+25
View File
@@ -0,0 +1,25 @@
package mapping
import (
"fmt"
"reflect"
"github.com/opencloud-eu/opencloud/pkg/conversions"
)
// PrepareForIndex converts v to the flat map[string]any the backend index
// clients expect: a json round-trip (conversions.To) plus type-specific
// adaptations (currently geopoint siblings). Pass the same overrides as the
// *BuildMapping calls so the document and the mapping stay in sync.
func PrepareForIndex(v any, overrides map[string]FieldOpts) (map[string]any, error) {
out, err := conversions.To[map[string]any](v)
if err != nil {
return nil, fmt.Errorf("mapping: prepare %T: %w", v, err)
}
if out == nil {
return out, nil
}
addGeopointSiblings(out, overrides)
addSearchSiblings(out, deref(reflect.TypeOf(v)), overrides)
return out, nil
}
@@ -0,0 +1,72 @@
package mapping
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PrepareForIndex serialization", func() {
It("errors for a non-marshallable value", func() {
// a func field can't be json-marshalled -> conversions.To errors
type bad struct {
F func() `json:"f"`
}
_, err := PrepareForIndex(bad{}, nil)
Expect(err).To(HaveOccurred())
})
It("returns nil map for a typed nil pointer", func() {
// a typed nil pointer marshals to null -> nil map, no error, no panic
out, err := PrepareForIndex((*struct{})(nil), nil)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(BeNil())
})
It("flattens embedded structs", func() {
type Inner struct {
Name string `json:"Name"`
Size uint64 `json:"Size"`
}
type outer struct {
Inner
ID string `json:"ID"`
}
m, err := PrepareForIndex(outer{Inner: Inner{Name: "a", Size: 7}, ID: "x"}, nil)
Expect(err).ToNot(HaveOccurred())
// keywords carry their lowercase and words search siblings by default
want := map[string]any{
"Name": "a", "Name_lowercase": "a", "Name_words": "a",
"Size": float64(7),
"ID": "x", "ID_lowercase": "x", "ID_words": "x",
}
Expect(m).To(Equal(want))
})
It("omits nil fields tagged omitempty", func() {
type facet struct {
Artist string `json:"artist"`
}
type doc struct {
Name string `json:"Name"`
Audio *facet `json:"audio,omitempty"`
}
m, err := PrepareForIndex(doc{Name: "n"}, nil)
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(HaveKey("audio"))
Expect(m["Name"]).To(Equal("n"))
})
It("includes nested facets when set", func() {
type facet struct {
Artist string `json:"artist"`
}
type doc struct {
Audio *facet `json:"audio,omitempty"`
}
m, err := PrepareForIndex(doc{Audio: &facet{Artist: "A"}}, nil)
Expect(err).ToNot(HaveOccurred())
nested, ok := m["audio"].(map[string]any)
Expect(ok).To(BeTrue(), "audio should be a nested map: %#v", m["audio"])
Expect(nested["artist"]).To(Equal("A"))
})
})
+92
View File
@@ -0,0 +1,92 @@
package mapping
import (
"fmt"
"reflect"
"sort"
"strings"
)
// Validate returns an error if any override key does not match a known field
// name in t. Top-level fields are identified by their json-tag name; nested
// named struct fields are reachable as "parent.child". Embedded (anonymous)
// structs are flattened, so their fields sit at the parent level (as with
// encoding/json).
func Validate(t reflect.Type, overrides map[string]FieldOpts) error {
if len(overrides) == 0 {
return nil
}
fields := collectFields(t, "")
var unknown, miscased, unbroken []string
for k, opts := range overrides {
goType, ok := fields[k]
if !ok {
unknown = append(unknown, k)
continue
}
if opts.NoWordBreaker != nil && !*opts.NoWordBreaker && !effectivelyKeyword(opts, goType) {
unbroken = append(unbroken, k)
}
// CaseInsensitive routes queries to a <field>_lowercase sibling, which is
// only generated for keyword/path fields; on any other type the query
// would target a non-existent field and silently match nothing. Use the
// effective type (override, else the inferred Go type), since an override
// with no explicit Type still infers keyword/numeric/... from the field.
if opts.CaseInsensitive != nil && *opts.CaseInsensitive && !effectivelyCased(opts, goType) {
miscased = append(miscased, k)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return fmt.Errorf("mapping: unknown override keys: %s", strings.Join(unknown, ", "))
}
if len(miscased) > 0 {
sort.Strings(miscased)
return fmt.Errorf("mapping: CaseInsensitive is only valid on keyword/path fields: %s", strings.Join(miscased, ", "))
}
if len(unbroken) > 0 {
sort.Strings(unbroken)
return fmt.Errorf("mapping: NoWordBreaker is only valid on keyword fields: %s", strings.Join(unbroken, ", "))
}
return nil
}
// effectivelyCased reports whether a field is keyword/path (the only types that
// get a _lowercase sibling), from the override type or the inferred Go type.
func effectivelyCased(opts FieldOpts, goType reflect.Type) bool {
eff := opts.Type
if eff == "" && goType != nil {
eff = inferType(goType)
}
return eff == TypeKeyword || eff == TypePath
}
// effectivelyKeyword reports whether a field is a keyword, the only type
// NoWordBreaker applies to.
func effectivelyKeyword(opts FieldOpts, goType reflect.Type) bool {
eff := opts.Type
if eff == "" && goType != nil {
eff = inferType(goType)
}
return eff == TypeKeyword
}
// collectFields maps every known field name (nested as "parent.child") to its Go
// type. Embedded structs are flattened, matching encoding/json.
func collectFields(t reflect.Type, prefix string) map[string]reflect.Type {
out := map[string]reflect.Type{}
_ = walkFields(t, func(fi fieldInfo) error {
key := fi.Name
if prefix != "" {
key = prefix + "." + fi.Name
}
out[key] = fi.GoField.Type
if sub := structType(fi.GoField.Type); sub != nil {
for k, v := range collectFields(sub, key) {
out[k] = v
}
}
return nil
})
return out
}
@@ -0,0 +1,91 @@
package mapping
import (
"reflect"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type inner struct {
Artist string `json:"artist"`
}
type sample struct {
Name string `json:"Name"`
Audio *inner `json:"audio,omitempty"`
Location *struct { //nolint:unused
Lon float64 `json:"longitude"`
Lat float64 `json:"latitude"`
} `json:"location,omitempty"`
}
var _ = Describe("Validate", func() {
It("accepts known override keys", func() {
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
"Name": {},
"audio": {Type: TypeObject},
"audio.artist": {},
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
})
It("rejects unknown override keys", func() {
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
"nope": {},
"audio.zzz": {},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("nope"))
Expect(err.Error()).To(ContainSubstring("audio.zzz"))
})
It("accepts empty overrides", func() {
Expect(Validate(reflect.TypeFor[sample](), nil)).To(Succeed())
})
It("rejects CaseInsensitive on a non-keyword/path field", func() {
True := true
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
"Name": {Type: TypeFulltext, CaseInsensitive: &True},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Name"))
Expect(err.Error()).To(ContainSubstring("CaseInsensitive"))
})
It("rejects switching NoWordBreaker off on a non-keyword field", func() {
False := false
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
"Name": {Type: TypeFulltext, NoWordBreaker: &False},
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Name"))
Expect(err.Error()).To(ContainSubstring("NoWordBreaker"))
})
It("rejects CaseInsensitive on an inferred non-keyword field (empty Type)", func() {
True := true
type doc struct {
Size uint64 `json:"Size"`
}
err := Validate(reflect.TypeFor[doc](), map[string]FieldOpts{
"Size": {CaseInsensitive: &True}, // no explicit Type -> inferred numeric
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Size"))
})
It("accepts CaseInsensitive on an inferred keyword field (empty Type)", func() {
True := true
type doc struct {
Name string `json:"Name"`
Tags []string `json:"Tags"`
}
Expect(Validate(reflect.TypeFor[doc](), map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Tags": {CaseInsensitive: &True},
})).To(Succeed())
})
})
+12 -15
View File
@@ -3,7 +3,6 @@ package opensearch
import (
"context"
"fmt"
"strings"
"time"
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
@@ -14,11 +13,11 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/pkg/kql"
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
searchQuery "github.com/opencloud-eu/opencloud/services/search/pkg/query"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -35,7 +34,7 @@ type Backend struct {
// NewBackend creates a backend on the versioned generation of the named index.
func NewBackend(name string, client *opensearchgoAPI.Client) (*Backend, error) {
index := IndexName(name)
index := VersionedIndexName(name)
pingResp, err := client.Ping(context.TODO(), &opensearchgoAPI.PingReq{})
switch {
@@ -74,7 +73,7 @@ func NewBackend(name string, client *opensearchgoAPI.Client) (*Backend, error) {
func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
boolQuery, err := convert.KQLToOpenSearchBoolQuery(sir.Query)
switch {
case searchQuery.IsValidationError(err):
case kql.IsValidationError(err):
return nil, errtypes.BadRequest(err.Error())
case err != nil:
return nil, fmt.Errorf("failed to convert KQL query to OpenSearch bool query: %w", err)
@@ -98,6 +97,15 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
),
),
)
// Scope below the space root: restrict at query level so totals and
// paging respect the path too. Path uses the case-preserving
// path_hierarchy analyzer, so the folder path is an indexed token of
// the folder itself and every descendant.
if requestedPath := utils.MakeRelativePath(sir.Ref.Path); requestedPath != "." {
boolQuery.Filter(
osu.NewTermQuery[string]("Path").Value(requestedPath),
)
}
}
searchParams := opensearchgoAPI.SearchParams{
@@ -150,17 +158,6 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
return nil, fmt.Errorf("failed to convert hit to match: %w", err)
}
if sir.Ref != nil {
hitPath := strings.TrimSuffix(match.GetEntity().GetRef().GetPath(), "/")
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
isRoot := hitPath == requestedPath
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
totalMatches--
continue
}
}
matches = append(matches, match)
}
@@ -1,6 +1,7 @@
package opensearch_test
import (
"context"
"testing"
. "github.com/onsi/ginkgo/v2"
@@ -8,6 +9,7 @@ import (
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
)
@@ -16,6 +18,12 @@ func TestOpenSearchBackend(t *testing.T) {
RunSpecs(t, "OpenSearch Backend Suite")
}
func deleteIndexOnCleanup(tc *opensearchtest.TestClient, indexName string) {
DeferCleanup(func() {
Expect(tc.IndicesDelete(context.Background(), []string{indexName})).To(Succeed())
})
}
var _ = Describe("Backend", func() {
Describe("NewBackend", func() {
It("fails to create if the cluster is not healthy", func() {
@@ -31,4 +39,44 @@ var _ = Describe("Backend", func() {
Expect(err).To(MatchError(opensearch.ErrUnhealthyCluster))
})
})
Describe("Upsert", func() {
const indexName = "opencloud-test-engine-upsert"
var (
tc *opensearchtest.TestClient
backend *opensearch.Backend
)
BeforeEach(func() {
// the backend versions the physical index by schema generation
physical := opensearch.VersionedIndexName(indexName)
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
tc.Require.IndicesReset([]string{physical})
deleteIndexOnCleanup(tc, physical)
var err error
backend, err = opensearch.NewBackend(indexName, tc.Client())
Expect(err).ToNot(HaveOccurred())
})
It("upserts a full document", func() {
document := opensearchtest.Testdata.Resources.File
Expect(backend.Upsert(document.ID, document)).To(Succeed())
tc.Require.IndicesCount([]string{opensearch.VersionedIndexName(indexName)}, nil, 1)
})
It("upserts a document without an mtime", func() {
// content.Extract leaves Mtime nil when the resource info carries none
document := opensearchtest.Testdata.Resources.File
document.ID = "1$1!4"
document.Mtime = nil
Expect(backend.Upsert(document.ID, document)).To(Succeed())
tc.Require.IndicesCount([]string{opensearch.VersionedIndexName(indexName)}, nil, 1)
})
})
})
+41 -17
View File
@@ -14,6 +14,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/conversions"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
@@ -43,7 +44,7 @@ func NewBatch(client *opensearchgoAPI.Client, index string, size int) (*Batch, e
func (b *Batch) Upsert(id string, r search.Resource) error {
return b.withSizeLimit(func() error {
body, err := conversions.To[map[string]any](r)
body, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
if err != nil {
return fmt.Errorf("failed to marshal resource: %w", err)
}
@@ -63,27 +64,46 @@ func (b *Batch) Upsert(id string, r search.Resource) error {
})
}
func (b *Batch) Move(id string, parentID string, targetPath string) error {
func (b *Batch) Move(id, parentID, location string) error {
return b.withSizeLimit(func() error {
op := func() error {
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(rootResource search.Resource) *osu.BodyParamScript {
newPath := utils.MakeRelativePath(location)
newName := path.Base(newPath)
return &osu.BodyParamScript{
Source: `
if (ctx._source.ID == params.id ) { ctx._source.Name = params.newName; ctx._source.ParentID = params.parentID; }
ctx._source.Path = ctx._source.Path.replace(params.oldPath, params.newPath);
boolean hidden = false;
for (String name : ctx._source.Path.splitOnToken('/')) {
if (!name.equals('.') && !name.equals('..') && name.startsWith('.')) { hidden = true; break; }
}
ctx._source.Hidden = hidden;
`,
// Keep Name and its search siblings in sync; Path has
// no sibling (case-sensitive by design). Only the leading
// oldPath is replaced (startsWith + substring, not
// String.replace, which would also rewrite a repeated segment
// deeper in a descendant's path, e.g. /Music/Music.m3u). The
// lowercased new name comes from Go's strings.ToLower via
// params, so the sibling stays byte-identical to what
// PrepareForIndex writes on upsert (painless toLowerCase would
// lowercase differently than Go).
Source: fmt.Sprintf(`
if (ctx._source.ID == params.id) {
ctx._source.Name = params.newName;
ctx._source.ParentID = params.parentID;
if (ctx._source.Name%[1]s != null) { ctx._source.Name%[1]s = params.newNameLower; }
if (ctx._source.Name%[2]s != null) { ctx._source.Name%[2]s = params.newName; }
}
if (ctx._source.Path != null && ctx._source.Path.startsWith(params.oldPath)) {
ctx._source.Path = params.newPath + ctx._source.Path.substring(params.oldPath.length());
}
boolean hidden = false;
for (String name : ctx._source.Path.splitOnToken('/')) {
if (!name.equals('.') && !name.equals('..') && name.startsWith('.')) { hidden = true; break; }
}
ctx._source.Hidden = hidden;
`, mapping.LowercaseSuffix, mapping.WordsSuffix),
Lang: "painless",
Params: map[string]any{
"id": id,
"parentID": parentID,
"oldPath": rootResource.Path,
"newPath": utils.MakeRelativePath(targetPath),
"newName": path.Base(utils.MakeRelativePath(targetPath)),
"id": id,
"parentID": parentID,
"oldPath": rootResource.Path,
"newPath": newPath,
"newName": newName,
"newNameLower": strings.ToLower(newName),
},
}
})
@@ -148,7 +168,11 @@ func (b *Batch) Purge(id string, onlyDeleted bool) error {
return fmt.Errorf("failed to get resource: %w", err)
}
query := osu.NewBoolQuery().Must(osu.NewTermQuery[string]("Path").Value(resource.Path))
// scope to the resource's space: the same path exists in other spaces
query := osu.NewBoolQuery().Must(
osu.NewTermQuery[string]("RootID").Value(resource.RootID),
osu.NewTermQuery[string]("Path").Value(resource.Path),
)
if onlyDeleted {
query.Must(osu.NewTermQuery[bool]("Deleted").Value(true))
}
+81 -40
View File
@@ -3,51 +3,38 @@ package opensearch
import (
"bytes"
"context"
"embed"
"errors"
"fmt"
"path"
"maps"
"reflect"
"strings"
"github.com/go-jose/go-jose/v3/json"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"github.com/tidwall/gjson"
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
var (
ErrManualActionRequired = errors.New("manual action required")
IndexManagerLatest = IndexIndexManagerResourceV3
IndexIndexManagerResourceV3 IndexManager = "resource_v3.json"
ErrManualActionRequired = errors.New("manual action required")
// IndexManagerLatest identifies the current resource mapping; its version is
// derived from search.SchemaVersion so it never drifts from the index name.
IndexManagerLatest = IndexManager(fmt.Sprintf("resource_v%d", search.SchemaVersion))
)
//go:embed internal/indexes/*.json
var indexes embed.FS
// VersionedIndexName suffixes the base index name with the schema version, e.g.
// "opencloud-resource" -> the name suffixed with the current schema version.
func VersionedIndexName(base string) string {
return fmt.Sprintf("%s-v%d", base, search.SchemaVersion)
}
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
// indexGenerators dispatches each IndexManager variant to its builder.
var indexGenerators = map[IndexManager]func() ([]byte, error){
IndexManagerLatest: buildResourceMapping,
}
func (m IndexManager) String() string {
@@ -60,16 +47,67 @@ func (m IndexManager) String() string {
}
func (m IndexManager) MarshalJSON() ([]byte, error) {
filePath := string(m)
body, err := indexes.ReadFile(path.Join("./internal/indexes", filePath))
switch {
case err != nil:
return nil, fmt.Errorf("failed to read index file %s: %w", filePath, err)
case len(body) <= 0:
return nil, fmt.Errorf("index file %s is empty", filePath)
gen, ok := indexGenerators[m]
if !ok {
return nil, fmt.Errorf("unknown index manager %q", string(m))
}
return gen()
}
// buildResourceMapping renders the OpenSearch index template for a
// search.Resource from the shared SearchFieldOverrides. OpenSearch-specific
// tweaks (wildcard MimeType, path_hierarchy Path) are applied on top.
func buildResourceMapping() ([]byte, error) {
resourceType := reflect.TypeFor[search.Resource]()
overrides := maps.Clone(search.Resource{}.SearchFieldOverrides())
mimeType := overrides["MimeType"]
mimeType.Type = searchmapping.TypeWildcard
overrides["MimeType"] = mimeType
if err := searchmapping.Validate(resourceType, overrides); err != nil {
return nil, err
}
props, err := searchmapping.OpenSearchBuildMapping(resourceType, overrides)
if err != nil {
return nil, err
}
return body, nil
index := map[string]any{
"settings": map[string]any{
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": map[string]any{
// path_hierarchy is case-preserving; casing lives in the value.
"analyzer": map[string]any{
"path_hierarchy": map[string]any{
"type": "custom",
"tokenizer": "path_hierarchy",
},
// words: split into lowercased words, a dot is a word boundary
// too so that "report" finds "Report.txt"; no stemming, a name
// is not prose
searchmapping.WordsAnalyzer: map[string]any{
"type": "custom",
"char_filter": []string{"dot_to_space"},
"tokenizer": "standard",
"filter": []string{"lowercase"},
},
},
"char_filter": map[string]any{
"dot_to_space": map[string]any{
"type": "mapping",
"mappings": []string{`. => \u0020`},
},
},
"tokenizer": map[string]any{
"path_hierarchy": map[string]any{"type": "path_hierarchy"},
},
},
},
"mappings": map[string]any{
"properties": props,
},
}
return json.Marshal(index)
}
func coveredAt(declared, index gjson.Result, declaredPath, indexPath string) (string, string, bool) {
@@ -163,8 +201,11 @@ func (m IndexManager) Apply(ctx context.Context, name string, client *opensearch
if errs != nil {
return fmt.Errorf(
"index %s already exists and is different from the requested version, %w: %w",
name,
"index %s already exists with a different mapping than the requested version. "+
"There is no in-place migration today: drop the index in OpenSearch (DELETE /%s) "+
"and restart the search service. The index will be recreated with the new mapping. "+
"%w: %w",
name, name,
ErrManualActionRequired,
errors.Join(errs...),
)
@@ -1,6 +1,7 @@
package opensearch_test
import (
"fmt"
"strings"
"testing"
@@ -9,8 +10,22 @@ import (
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// TestVersionedIndexName guards that the index name and the generator identity
// carry the same schema version.
func TestVersionedIndexName(t *testing.T) {
require.Equal(t,
fmt.Sprintf("opencloud-resource-v%d", search.SchemaVersion),
opensearch.VersionedIndexName("opencloud-resource"),
)
require.Equal(t,
fmt.Sprintf("resource_v%d", search.SchemaVersion),
string(opensearch.IndexManagerLatest),
)
}
func TestIndexManager(t *testing.T) {
t.Run("index plausibility", func(t *testing.T) {
tests := []opensearchtest.TableTest[opensearch.IndexManager, struct{}]{
@@ -1,208 +0,0 @@
package convert
import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/opencloud/pkg/ast"
)
func ExpandKQL(nodes []ast.Node) ([]ast.Node, error) {
return kqlExpander{}.expand(nodes, "")
}
type kqlExpander struct{}
func (e kqlExpander) expand(nodes []ast.Node, defaultKey string) ([]ast.Node, error) {
for i, node := range nodes {
rnode := reflect.ValueOf(node)
// we need to ensure that the node is a pointer to an ast.Node in every case
if rnode.Kind() != reflect.Ptr {
ptr := reflect.New(rnode.Type())
ptr.Elem().Set(rnode)
rnode = ptr
cnode, ok := rnode.Interface().(ast.Node)
if !ok {
return nil, fmt.Errorf("expected node to be of type ast.Node, got %T", rnode.Interface())
}
node = cnode // Update the original node to the pointer
nodes[i] = node // Update the original slice with the pointer
}
var unfoldedNodes []ast.Node
switch cnode := node.(type) {
case *ast.GroupNode:
if cnode.Key != "" { // group nodes should not get a default key
cnode.Key = e.remapKey(cnode.Key, defaultKey)
}
groupNodes, err := e.expand(cnode.Nodes, cnode.Key)
if err != nil {
return nil, err
}
cnode.Nodes = groupNodes
case *ast.StringNode:
cnode.Key = e.remapKey(cnode.Key, defaultKey)
cnode.Value = e.lowerValue(cnode.Key, cnode.Value)
unfoldedNodes = e.unfoldValue(cnode.Key, cnode.Value)
case *ast.DateTimeNode:
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 {
// Insert unfolded nodes at the current index
nodes = append(nodes[:i], append(unfoldedNodes, nodes[i+1:]...)...)
// Adjust index to account for new nodes
i += len(unfoldedNodes) - 1
}
}
return nodes, nil
}
func (_ kqlExpander) remapKey(current string, defaultKey string) string {
if defaultKey == "" {
defaultKey = "Name" // Set a default key if none is provided
}
key, ok := map[string]string{
"": defaultKey, // Default case if current is empty
"title": "Title",
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"hidden": "Hidden",
"favorite": "Favorites",
}[strings.ToLower(current)]
if !ok {
return current // Return the original key if not found
}
return key
}
func (_ kqlExpander) lowerValue(key, value string) string {
if slices.Contains([]string{"Name", "Title", "Tags", "Content", "MimeType", "Type", "Hidden"}, key) {
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"},
},
"MimeType:folder": {
&ast.StringNode{Key: key, Value: "httpd/unix-directory"},
},
"MimeType:document": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/msword"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.form"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.text"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/plain"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/markdown"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/rtf"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.pages"},
}},
},
"MimeType:spreadsheet": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/vnd.ms-excel"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.spreadsheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "text/csv"},
&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.apple.numbers"},
}},
},
"MimeType:presentation": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.oasis.opendocument.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.ms-powerpoint"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/vnd.apple.keynote"},
}},
},
"MimeType:pdf": {
&ast.StringNode{Key: key, Value: "application/pdf"},
},
"MimeType:image": {
&ast.StringNode{Key: key, Value: "image/*"},
},
"MimeType:video": {
&ast.StringNode{Key: key, Value: "video/*"},
},
"MimeType:audio": {
&ast.StringNode{Key: key, Value: "audio/*"},
},
"MimeType:archive": {
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: key, Value: "application/zip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-7z-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-rar-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-tar"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-bzip2"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-bzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: key, Value: "application/x-tgz"},
}},
},
}[fmt.Sprintf("%s:%s", key, value)]
if !ok {
return nil
}
return result
}
@@ -1,643 +0,0 @@
package convert_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/internal/opensearchtest"
)
func TestExpandKQLAST(t *testing.T) {
t.Run("always converts a value node to a pointer node", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "ast.node.V -> ast.node.PTR",
Got: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "c"},
&ast.OperatorNode{Value: "OR"},
ast.DateTimeNode{Key: "d"},
ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: "f"},
&ast.OperatorNode{Value: "NOT"},
ast.BooleanNode{Key: "g"},
ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
}},
}},
}},
ast.GroupNode{Key: "i", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "OR"},
ast.GroupNode{Key: "h", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
ast.OperatorNode{Value: "OR"},
ast.GroupNode{Key: "h", Nodes: []ast.Node{
ast.StringNode{Key: "a"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "b"},
}},
}},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "c"},
&ast.OperatorNode{Value: "OR"},
&ast.DateTimeNode{Key: "d"},
&ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: "f"},
&ast.OperatorNode{Value: "NOT"},
&ast.BooleanNode{Key: "g"},
&ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
}},
}},
}},
&ast.GroupNode{Key: "i", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: "h", Nodes: []ast.Node{
&ast.StringNode{Key: "a"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "b"},
}},
}},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("remaps some keys", func(t *testing.T) {
var tests []opensearchtest.TableTest[[]ast.Node, []ast.Node]
for k, v := range map[string]string{
"": "Name", // Default to "Name" if no key is provided
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"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]{
Name: fmt.Sprintf("%s -> %s", k, v),
Got: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: k},
&ast.OperatorNode{Value: "OR"},
ast.DateTimeNode{Key: k},
ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: k},
&ast.OperatorNode{Value: "NOT"},
ast.BooleanNode{Key: k},
ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: k, Nodes: []ast.Node{
&ast.StringNode{Key: k},
&ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: k},
}},
}},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.DateTimeNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.BooleanNode{Key: v},
&ast.OperatorNode{Value: "NOT"},
&ast.BooleanNode{Key: v},
&ast.OperatorNode{Value: "NOT"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "OR"},
&ast.GroupNode{Key: func() string {
switch {
case k == "":
return k
default:
return v
}
}(), Nodes: []ast.Node{
&ast.StringNode{Key: v},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: v},
}},
}},
}},
},
})
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("lowercases some values", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "Name: StringNode -> stringnode",
Got: []ast.Node{
ast.StringNode{Key: "Name", Value: "StringNode"},
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
ast.StringNode{Key: "Name", Value: "StringNode"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Name", Value: "stringnode"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "stringnode"},
}},
},
},
{
Name: "aBc: StringNode -> StringNode",
Got: []ast.Node{
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: "Path", Value: "./Documents"},
}},
},
Want: []ast.Node{
&ast.StringNode{Key: "Path", Value: "./Documents"},
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
&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 {
t.Run(test.Name, func(t *testing.T) {
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.Equal(t, test.Want, result)
})
}
})
t.Run("unfolds some values", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "MimeType:unknown",
Got: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "unknown"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "some-name"},
},
Want: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "unknown"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:file",
Got: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "file"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "some-name"},
},
Want: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:folder",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "folder"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:document",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "document"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/msword"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.wordprocessingml.form"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.text"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/plain"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/markdown"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/rtf"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.pages"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:spreadsheet",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "spreadsheet"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/vnd.ms-excel"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.spreadsheet"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "text/csv"},
&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.apple.numbers"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:presentation",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "presentation"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/vnd.openxmlformats-officedocument.presentationml.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.oasis.opendocument.presentation"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.ms-powerpoint"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/vnd.apple.keynote"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:pdf",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "pdf"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "application/pdf"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:image",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "image"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "image/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:video",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "video"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "video/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:audio",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "audio"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "MimeType", Value: "audio/*"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
{
Name: "MimeType:archive",
Got: []ast.Node{
ast.BooleanNode{Key: "Deleted", Value: false},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Key: "MimeType", Value: "archive"},
ast.OperatorNode{Value: "AND"},
ast.StringNode{Value: "some-name"},
},
Want: []ast.Node{
&ast.BooleanNode{Key: "Deleted", Value: false},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/zip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-gzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-7z-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-rar-compressed"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-tar"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-bzip2"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-bzip"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "MimeType", Value: "application/x-tgz"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: `some-name`},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
if test.Skip {
t.Skip("Skipping test due to known issue")
}
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.EqualValues(t, test.Want, result)
})
}
})
t.Run("different cases", func(t *testing.T) {
tests := []opensearchtest.TableTest[[]ast.Node, []ast.Node]{
{
Name: "use the group node key as default key",
Got: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "a", Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "mediatype", Nodes: []ast.Node{
&ast.StringNode{Value: "file"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "mediatype", Value: "file"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
},
Want: []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "a", Nodes: []ast.Node{
&ast.StringNode{Key: "a", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Key: "MimeType", Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "c", Value: "d"},
}},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
if test.Skip {
t.Skip("Skipping test due to known issue")
}
result, err := convert.ExpandKQL(test.Got)
require.NoError(t, err)
require.EqualValues(t, test.Want, result)
})
}
})
}
@@ -5,6 +5,7 @@ import (
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
var (
@@ -17,12 +18,10 @@ func KQLToOpenSearchBoolQuery(kqlQuery string) (*osu.BoolQuery, error) {
return nil, err
}
kqlNodes, err := ExpandKQL(kqlAst.Nodes)
if err != nil {
return nil, fmt.Errorf("failed to expand KQL AST nodes: %w", err)
}
// shared lowering: field resolution, media-type expansion, value lowercasing.
kqlAst = query.Normalize(kqlAst, query.ResolveField)
builder, err := TranspileKQLToOpenSearch(kqlNodes)
builder, err := TranspileKQLToOpenSearch(kqlAst.Nodes)
if err != nil {
return nil, fmt.Errorf("failed to compile query: %w", err)
}
@@ -10,7 +10,9 @@ import (
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
func TranspileKQLToOpenSearch(nodes []ast.Node) (osu.Builder, error) {
@@ -48,13 +50,21 @@ func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error
nextOp := t.getOperatorValueAt(nodes, i+1)
prevOp := t.getOperatorValueAt(nodes, i-1)
// A preceding NOT negates this node regardless of what follows (NOT x AND y
// is (NOT x) AND y), so it must win over nextOp. The prevOp AND/OR cases
// give the right operand its own bucket instead of inheriting the previous
// one, which matters right after a NOT (its MustNot must not carry over).
switch {
case prevOp == kql.BoolNOT:
boolQueryAdd = boolQuery.MustNot
case nextOp == kql.BoolOR:
boolQueryAdd = boolQuery.Should
case nextOp == kql.BoolAND:
boolQueryAdd = boolQuery.Must
case prevOp == kql.BoolNOT:
boolQueryAdd = boolQuery.MustNot
case prevOp == kql.BoolOR:
boolQueryAdd = boolQuery.Should
case prevOp == kql.BoolAND:
boolQueryAdd = boolQuery.Must
}
builder, err := t.toBuilder(node)
@@ -71,7 +81,7 @@ func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error
continue
}
if nextOp == kql.BoolOR {
if nextOp == kql.BoolOR || prevOp == kql.BoolOR {
// if there are should clauses, we set the minimum should match to 1
boolQueryParams.MinimumShouldMatch = 1
}
@@ -99,7 +109,72 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
case *ast.BooleanNode:
return osu.NewTermQuery[bool](node.Key).Value(node.Value), nil
case *ast.StringNode:
return stringNodeQuery(node), nil
// hidden takes bool words only; anything else matches nothing
if node.Key == "Hidden" {
b, err := strconv.ParseBool(node.Value)
if err != nil {
return osu.NewMatchNoneQuery(), nil
}
return osu.NewTermQuery[bool](node.Key).Value(b), nil
}
field, value := node.Key, node.Value
if query.FieldIsPath(node.Key) {
value = strings.TrimSuffix(value, "/")
}
if node.CaseInsensitive {
field += mapping.LowercaseSuffix
value = strings.ToLower(value)
}
if isWildcard := strings.ContainsAny(value, "*?"); isWildcard {
// a wildcard on a word-broken field forgives a missing extension:
// *report also matches Report.txt
if query.FieldIsWordBroken(node.Key) && !strings.HasSuffix(value, "*") {
return osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewWildcardQuery(field).Value(value),
osu.NewWildcardQuery(field).Value(value+".*"),
), nil
}
return osu.NewWildcardQuery(field).Value(value), nil
}
// = matches the whole value, on the lowercased sibling for
// case-insensitive fields
if node.Exact {
return osu.NewTermQuery[string](field).Value(value), nil
}
// a word-broken field matches the value as a phrase of its words on the
// _words sibling, whose analyzer lowercases; wildcards stay on _lowercase
if query.FieldIsWordBroken(node.Key) {
return osu.NewMatchPhraseQuery(node.Key + mapping.WordsSuffix).Query(node.Value), nil
}
if query.FieldIsFulltext(node.Key) {
return osu.NewMatchPhraseQuery(field).Query(value), nil
}
// a path value is a single term in the path_hierarchy token stream; a
// phrase match would analyze the query into its path prefixes and match
// everything under the root, so paths with spaces must stay term queries.
if query.FieldIsPath(node.Key) {
return osu.NewTermQuery[string](field).Value(value), nil
}
totalTerms := strings.Split(value, " ")
isSingleTerm := len(totalTerms) == 1
isMultiTerm := len(totalTerms) >= 1
switch {
case isSingleTerm:
return osu.NewTermQuery[string](field).Value(value), nil
case isMultiTerm:
return osu.NewMatchPhraseQuery(field).Query(value), nil
}
return nil, fmt.Errorf("unsupported string node value: %s", value)
case *ast.DateTimeNode:
return dateTimeNodeQuery(node)
case *ast.NumberNode:
@@ -116,67 +191,26 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
return nil, fmt.Errorf("%w: %T", ErrUnsupportedNodeType, node)
}
// stringNodeQuery picks the query a string node turns into.
func stringNodeQuery(node *ast.StringNode) osu.Builder {
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"}, 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 + ".wildcard").
Value(node.Value).
Params(&osu.TermQueryParams{CaseInsensitive: true})
// Name: "foo-bar", "foo bar"
// Title: "quarterly report"
// Content: "foo bar"
case slices.Contains([]string{"Name", "Title", "Content"}, node.Key):
return osu.NewMatchPhraseQuery(node.Key).Query(node.Value)
// 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)
// dateTimeNodeQuery turns a date time node into a range query.
func dateTimeNodeQuery(node *ast.DateTimeNode) (osu.Builder, error) {
if node.Operator == nil {
return nil, fmt.Errorf("date time node without operator: %w", ErrUnsupportedNodeType)
}
query := osu.NewRangeQuery[time.Time](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 date time node: %w", node.Operator.Value, ErrUnsupportedNodeType)
}
func numberNodeQuery(node *ast.NumberNode) (osu.Builder, error) {
@@ -203,31 +237,3 @@ func numberNodeQuery(node *ast.NumberNode) (osu.Builder, error) {
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 {
return nil, fmt.Errorf("date time node without operator: %w", ErrUnsupportedNodeType)
}
query := osu.NewRangeQuery[time.Time](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 date time node: %w", node.Operator.Value, ErrUnsupportedNodeType)
}
@@ -16,13 +16,40 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
tests := []opensearchtest.TableTest[*ast.Ast, osu.Builder]{
// kql to os dsl - type tests
{
Name: "match phrase query - string node on an analyzed field",
Name: "word-broken field matches the value as a phrase on its words sibling",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "openCloud"},
},
},
Want: osu.NewMatchPhraseQuery("Name").Query("openCloud"),
Want: osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
},
{
Name: "case-insensitive term routes to the lowercased sibling",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Tags", Value: "openCloud", CaseInsensitive: true},
},
},
Want: osu.NewTermQuery[string]("Tags_lowercase").Value("opencloud"),
},
{
Name: "case-insensitive wildcard routes to the lowercased sibling",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "Open*", CaseInsensitive: true},
},
},
Want: osu.NewWildcardQuery("Name_lowercase").Value("open*"),
},
{
Name: "full-text field uses an analyzed match query, not an unanalyzed term",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Content", Value: "Running"},
},
},
Want: osu.NewMatchPhraseQuery("Content").Query("Running"),
},
{
Name: "term query - boolean node - true",
@@ -49,7 +76,7 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
&ast.StringNode{Key: "Name", Value: "open cloud"},
},
},
Want: osu.NewMatchPhraseQuery("Name").Query(`open cloud`),
Want: osu.NewMatchPhraseQuery("Name_words").Query(`open cloud`),
},
{
Name: "wildcard query - string node",
@@ -58,16 +85,10 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
&ast.StringNode{Key: "Name", Value: "open*"},
},
},
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewWildcardQuery("Name.wildcard").
Value("open*").
Params(&osu.WildcardQueryParams{CaseInsensitive: true}),
),
Want: osu.NewWildcardQuery("Name").Value("open*"),
},
{
Name: "wildcard query - string node without an unanalyzed sub field",
Name: "wildcard query - fulltext field",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Content", Value: "open*"},
@@ -76,60 +97,24 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
Want: osu.NewWildcardQuery("Content").Value("open*"),
},
{
Name: "wildcard query - a question mark counts as a wildcard",
// a phrase match would analyze the query with path_hierarchy and match
// everything under the root
Name: "path with spaces stays an unanalyzed term query",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "fo?o"},
&ast.StringNode{Key: "Path", Value: "./parent d!r/child.pdf"},
},
},
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}),
),
Want: osu.NewTermQuery[string]("Path").Value("./parent d!r/child.pdf"),
},
{
Name: "term query - an equals restriction matches the whole name",
Name: "case-insensitive path with spaces routes to the lowercased sibling as a term query",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "foo bar.txt", Exact: true},
&ast.StringNode{Key: "Path", Value: "./Parent Dir", CaseInsensitive: 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(),
Want: osu.NewTermQuery[string]("Path_lowercase").Value("./parent dir"),
},
{
Name: "bool query",
@@ -142,8 +127,8 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
},
},
Want: osu.NewBoolQuery().Must(
osu.NewMatchPhraseQuery("Name").Query("a"),
osu.NewMatchPhraseQuery("Name").Query("b"),
osu.NewMatchPhraseQuery("Name_words").Query("a"),
osu.NewMatchPhraseQuery("Name_words").Query("b"),
),
},
{
@@ -155,7 +140,7 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
}},
},
},
Want: osu.NewMatchPhraseQuery("Name").Query("any"),
Want: osu.NewMatchPhraseQuery("Name_words").Query("any"),
},
{
Name: "range query >",
@@ -217,7 +202,7 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
&ast.StringNode{Key: "Name", Value: "openCloud"},
},
},
Want: osu.NewMatchPhraseQuery("Name").Query("openCloud"),
Want: osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
},
{
Name: "[* *]",
@@ -229,7 +214,7 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
},
Want: osu.NewBoolQuery().
Must(
osu.NewMatchPhraseQuery("Name").Query("openCloud"),
osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
osu.NewTermQuery[string]("age").Value("32"),
),
},
@@ -244,7 +229,7 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
},
Want: osu.NewBoolQuery().
Must(
osu.NewMatchPhraseQuery("Name").Query("openCloud"),
osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
osu.NewTermQuery[string]("age").Value("32"),
),
},
@@ -260,7 +245,7 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewMatchPhraseQuery("Name").Query("openCloud"),
osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
osu.NewTermQuery[string]("age").Value("32"),
),
},
@@ -288,12 +273,32 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
},
Want: osu.NewBoolQuery().
Must(
osu.NewMatchPhraseQuery("Name").Query("openCloud"),
osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
).
MustNot(
osu.NewTermQuery[string]("age").Value("32"),
),
},
{
// NOT binds to the node directly after it, not to whatever operator
// follows that node: NOT x AND y is (NOT x) AND y.
Name: "[NOT * AND *]",
Got: &ast.Ast{
Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "age", Value: "32"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Name", Value: "openCloud"},
},
},
Want: osu.NewBoolQuery().
MustNot(
osu.NewTermQuery[string]("age").Value("32"),
).
Must(
osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
),
},
{
Name: "[* OR * OR *]",
Got: &ast.Ast{
@@ -308,7 +313,7 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
Want: osu.NewBoolQuery().
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
Should(
osu.NewMatchPhraseQuery("Name").Query("openCloud"),
osu.NewMatchPhraseQuery("Name_words").Query("openCloud"),
osu.NewTermQuery[string]("age").Value("32"),
osu.NewTermQuery[string]("age").Value("44"),
),
@@ -3,7 +3,6 @@ package convert
import (
"fmt"
"strings"
"time"
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -15,6 +14,17 @@ import (
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// copyFacet converts a typed pointer from the indexed shape (libregraph) to
// the protobuf shape via conversions.To. Returns nil when src is nil so the
// enclosing Match.Entity field stays nil.
func copyFacet[Dst, Src any](src *Src) *Dst {
if src == nil {
return nil
}
dst, _ := conversions.To[*Dst](src)
return dst
}
func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, error) {
resource, err := conversions.To[search.Resource](hit.Source)
if err != nil {
@@ -69,31 +79,15 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match,
return strings.Join(contentHighlights[:], "; ")
}(),
Audio: func() *searchMessage.Audio {
if !strings.HasPrefix(resource.MimeType, "audio/") {
return nil
}
audio, _ := conversions.To[*searchMessage.Audio](resource.Audio)
return audio
}(),
Image: func() *searchMessage.Image {
image, _ := conversions.To[*searchMessage.Image](resource.Image)
return image
}(),
Location: func() *searchMessage.GeoCoordinates {
geoCoordinates, _ := conversions.To[*searchMessage.GeoCoordinates](resource.Location)
return geoCoordinates
}(),
Photo: func() *searchMessage.Photo {
photo, _ := conversions.To[*searchMessage.Photo](resource.Photo)
return photo
}(),
Audio: copyFacet[searchMessage.Audio](resource.Audio),
Image: copyFacet[searchMessage.Image](resource.Image),
Location: copyFacet[searchMessage.GeoCoordinates](resource.Location),
Photo: copyFacet[searchMessage.Photo](resource.Photo),
},
}
if mtime, err := time.Parse(time.RFC3339, resource.Mtime); err == nil {
match.Entity.LastModifiedTime = &timestamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
if resource.Mtime != nil {
match.Entity.LastModifiedTime = timestamppb.New(*resource.Mtime)
}
return match, nil
@@ -36,7 +36,7 @@ var _ = Describe("OpenSearchHitToMatch", func() {
resource = opensearchtest.Testdata.Resources.File
resource.MimeType = "audio/mpeg"
mtime = time.Date(2025, 7, 24, 15, 15, 1, 0, time.UTC)
resource.Mtime = mtime.Format(time.RFC3339)
resource.Mtime = &mtime
resource.Favorites = []string{"cbf24bce-3e6e-4d9e-a2a2-cbf24bce3e6e"}
hit = opensearchgoAPI.SearchHit{
@@ -1,49 +0,0 @@
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": {
"analyzer": {
"path_hierarchy": {
"filter": [
"lowercase"
],
"tokenizer": "path_hierarchy",
"type": "custom"
}
},
"tokenizer": {
"path_hierarchy": {
"type": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"ID": {
"type": "keyword"
},
"ParentID": {
"type": "keyword"
},
"RootID": {
"type": "keyword"
},
"MimeType": {
"type": "wildcard",
"doc_values": false
},
"Path": {
"type": "text",
"analyzer": "path_hierarchy"
},
"Deleted": {
"type": "boolean"
},
"Hidden": {
"type": "boolean"
}
}
}
}
@@ -1,56 +0,0 @@
{
"settings": {
"number_of_shards": "1",
"number_of_replicas": "1",
"analysis": {
"analyzer": {
"path_hierarchy": {
"filter": [
"lowercase"
],
"tokenizer": "path_hierarchy",
"type": "custom"
}
},
"tokenizer": {
"path_hierarchy": {
"type": "path_hierarchy"
}
}
}
},
"mappings": {
"properties": {
"Content": {
"type": "text",
"term_vector": "with_positions_offsets"
},
"ID": {
"type": "keyword"
},
"ParentID": {
"type": "keyword"
},
"RootID": {
"type": "keyword"
},
"MimeType": {
"type": "wildcard",
"doc_values": false
},
"Path": {
"type": "text",
"analyzer": "path_hierarchy"
},
"Deleted": {
"type": "boolean"
},
"Hidden": {
"type": "boolean"
},
"Favorites": {
"type": "keyword"
}
}
}
}
@@ -1,122 +0,0 @@
{
"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"
}
}
}
]
}
}
+8 -1
View File
@@ -181,6 +181,10 @@ Fixtures:
| MEDIATYPE-04 | `mediatype:*jpeg` | photo.jpg | photo.jpg | photo.jpg | ✅ |
| MEDIATYPE-05 | `mediatype:image` | photo.jpg | photo.jpg | photo.jpg | ✅ |
| MEDIATYPE-06 | `mediatype:folder` | albums, drafts | albums, drafts | albums, drafts | ✅ |
| MEDIATYPE-07 | `mediatype:file` | notes.md, photo.jpg | notes.md, photo.jpg | notes.md, photo.jpg | ✅ |
| MEDIATYPE-08 | `NOT mediatype:file` | albums, drafts | albums, drafts | albums, drafts | ✅ |
| MEDIATYPE-09 | `mediatype:file OR mediatype:image` | notes.md, photo.jpg | notes.md, photo.jpg | notes.md, photo.jpg | ✅ |
| MEDIATYPE-10 | `NOT mediatype:(image OR folder)` | notes.md | notes.md | notes.md | ✅ |
### path
@@ -232,7 +236,8 @@ Fixtures:
| FIELDS-11 | `id:"1$1!AB-23"` | cased.txt | cased.txt | cased.txt | ✅ |
| FIELDS-12 | `id:"1$1!ab-23"` | no match | no match | no match | ✅ |
| FIELDS-13 | `audio.artist:"Some Artist"` | song.mp3 | song.mp3 | song.mp3 | ✅ |
| FIELDS-14 | `audio.artist:"some artist"` | no match | no match | no match | ✅ |
| FIELDS-14 | `audio.artist:"some artist"` | song.mp3 | song.mp3 | song.mp3 | ✅ |
| FIELDS-15 | `audio.duration>100` | no match | no match | no match | ✅ |
### deleted
@@ -499,6 +504,8 @@ Fixtures:
| ROOTSCOPE-01 | deletes only the one in the target root, then `name:"*twin*"` | twin.txt | twin.txt | twin.txt | ✅ |
| ROOTSCOPE-02 | restores only the one in the target root, then `name:"*target*"` | target.txt | target.txt | target.txt | ✅ |
| ROOTSCOPE-02 | restores only the one in the target root, then `name:"*twin*"` | no match | no match | no match | ✅ |
| ROOTSCOPE-04 | purges only the one in the target root, then `name:"*target*"` | no match | no match | no match | ✅ |
| ROOTSCOPE-04 | purges only the one in the target root, then `name:"*twin*"` | twin.txt | twin.txt | twin.txt | ✅ |
| ROOTSCOPE-03 | moves only the one in the target root, then `path:"./moved.txt"` | moved.txt | moved.txt | moved.txt | ✅ |
| ROOTSCOPE-03 | moves only the one in the target root, then `path:"./same/path.txt"` | twin.txt | twin.txt | twin.txt | ✅ |
+1 -1
View File
@@ -92,7 +92,7 @@ func newOpenSearch(name string, fixtures []search.Resource) testEngine {
GinkgoHelper()
tc := opensearchtest.NewDefaultTestClient(GinkgoTB(), openSearchClient)
index := opensearch.IndexName(name)
index := opensearch.VersionedIndexName(name)
if err := tc.IndicesReset(context.Background(), []string{index}); err != nil {
return testEngine{name: "opensearch", unavailable: err.Error()}
+11 -2
View File
@@ -24,7 +24,15 @@ func withMime(mime string) fixtureOption { return func(r *search.Resource) { r.M
func withTitle(t string) fixtureOption { return func(r *search.Resource) { r.Title = t } }
func withContent(c string) fixtureOption { return func(r *search.Resource) { r.Content = c } }
func withSize(s uint64) fixtureOption { return func(r *search.Resource) { r.Size = s } }
func withMtime(m string) fixtureOption { return func(r *search.Resource) { r.Mtime = m } }
func withMtime(m string) fixtureOption {
return func(r *search.Resource) {
t, err := time.Parse(time.RFC3339Nano, m)
if err != nil {
panic(err)
}
r.Mtime = &t
}
}
func withID(id string) fixtureOption { return func(r *search.Resource) { r.ID = id } }
func withParent(id string) fixtureOption { return func(r *search.Resource) { r.ParentID = id } }
func withRoot(id string) fixtureOption { return func(r *search.Resource) { r.RootID = id } }
@@ -47,6 +55,7 @@ func withLocation(location *libregraph.GeoCoordinates) fixtureOption {
}
func fixtureDoc(name string, opts ...fixtureOption) search.Resource {
mtime := fixtureNow
r := search.Resource{
ID: "1$1!" + name,
RootID: "1$1!1",
@@ -56,7 +65,7 @@ func fixtureDoc(name string, opts ...fixtureOption) search.Resource {
Document: content.Document{
Name: name,
MimeType: "text/plain",
Mtime: fixtureNow.Format(time.RFC3339Nano),
Mtime: &mtime,
Size: 1000,
},
}
@@ -37,6 +37,14 @@ func rootScopeLifecycle() lifecycleGroup {
{`name:"*twin*"`, nil},
},
},
{
id: 4, title: "purges only the one in the target root",
do: func(e search.Engine) error { return e.Purge(target.ID, false) },
expect: []expectation{
{`name:"*target*"`, nil},
{`name:"*twin*"`, []string{"twin.txt"}},
},
},
{
id: 3, title: "moves only the one in the target root",
do: func(e search.Engine) error { return e.Move(target.ID, target.ParentID, "./moved.txt") },
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"os"
"sort"
"strings"
"time"
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
@@ -336,8 +337,8 @@ func fixtureFields(f search.Resource, withID bool) string {
add("Size = %d", f.Size)
}
if !strings.HasPrefix(f.Mtime, fixtureNow.Format("2006-01-02")) {
add("Mtime = %s", f.Mtime)
if f.Mtime == nil || f.Mtime.Format("2006-01-02") != fixtureNow.Format("2006-01-02") {
add("Mtime = %s", f.Mtime.Format(time.RFC3339))
}
if f.Hidden {
@@ -18,7 +18,7 @@ func fieldsGroup() queryGroup {
fixtureDoc("plain.txt"),
fixtureFolder("box"),
fixtureDoc("boxed.txt", withParent("1$1!box"), withPath("./box/boxed.txt")),
fixtureDoc("song.mp3", withMime("audio/mpeg"), withAudio(&libregraph.Audio{Artist: libregraph.PtrString("Some Artist")})),
fixtureDoc("song.mp3", withMime("audio/mpeg"), withAudio(&libregraph.Audio{Artist: libregraph.PtrString("Some Artist"), Duration: libregraph.PtrInt64(200)})),
},
cases: []queryCase{
{id: 1, query: `size:42`, want: []string{"small.txt"}},
@@ -35,7 +35,8 @@ func fieldsGroup() queryGroup {
{id: 12, query: `id:"1$1!ab-23"`},
// a facet value keeps its case, the field is not marked lowercase
{id: 13, query: `audio.artist:"Some Artist"`, want: []string{"song.mp3"}},
{id: 14, query: `audio.artist:"some artist"`},
{id: 14, query: `audio.artist:"some artist"`, want: []string{"song.mp3"}}, // facets search case-insensitively
{id: 15, query: `audio.duration>100`}, // number queries are gated to Size and Type on both engines
},
}
}
@@ -20,6 +20,10 @@ func mediatypeGroup() queryGroup {
{id: 4, query: `mediatype:*jpeg`, want: []string{"photo.jpg"}},
{id: 5, query: `mediatype:image`, want: []string{"photo.jpg"}},
{id: 6, query: `mediatype:folder`, want: []string{"albums", "drafts"}},
{id: 7, query: `mediatype:file`, want: []string{"notes.md", "photo.jpg"}},
{id: 8, query: `NOT mediatype:file`, want: []string{"albums", "drafts"}},
{id: 9, query: `mediatype:file OR mediatype:image`, want: []string{"notes.md", "photo.jpg"}},
{id: 10, query: `NOT mediatype:(image OR folder)`, want: []string{"notes.md"}},
},
}
}
+4
View File
@@ -22,6 +22,10 @@ func (c Creator[T]) Create(qs string) (T, error) {
return t, err
}
// shared KQL lowering pass: resolve field names + expand media-type aliases
// once, so the compiler below sees only canonical field:value nodes.
builderAst = query.Normalize(builderAst, query.ResolveField)
t, err = c.compiler.Compile(builderAst)
if err != nil {
return t, err
+90 -206
View File
@@ -6,41 +6,14 @@ import (
"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"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
searchQuery "github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
var lowercaseFields = map[string]struct{}{
"Name": {},
"Title": {},
"Tags": {},
"Favorites": {},
"Content": {},
"MimeType": {},
"Hidden": {},
}
var _fields = map[string]string{
"rootid": "RootID",
"path": "Path",
"id": "ID",
"name": "Name",
"size": "Size",
"mtime": "Mtime",
"mediatype": "MimeType",
"type": "Type",
"tag": "Tags",
"tags": "Tags",
"content": "Content",
"title": "Title",
"hidden": "Hidden",
"favorite": "Favorites",
}
// The following quoted string enumerates the characters which may be escaped: "+-=&|><!(){}[]^\"~*?:\\/ "
// based on bleve docs https://blevesearch.com/docs/Query-String-Query/
// Wildcards * and ? are excluded
@@ -99,55 +72,83 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
for i := offset; i < len(nodes); i++ {
switch n := nodes[i].(type) {
case *ast.StringNode:
k := getField(n.Key)
v := n.Value
if k != "ID" && k != "Size" {
v = bleveEscaper.Replace(n.Value)
}
if _, ok := lowercaseFields[k]; ok {
v = strings.ToLower(v)
}
if k == "Type" {
v = resourceType(v)
}
var q bleveQuery.Query
var group bool
switch {
case k == "Hidden":
value, err := strconv.ParseBool(v)
if err != nil {
// hidden takes bool words only; anything else matches nothing
if n.Key == "Hidden" {
var q bleveQuery.Query
if b, err := strconv.ParseBool(n.Value); err == nil {
bq := bleveQuery.NewBoolFieldQuery(b)
bq.SetField(n.Key)
q = bq
} else {
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+".*"))
prev = q
} else {
next = q
}
break
}
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)
// keys are resolved and media-type expanded by normalize. MimeType
// skips the escaper so the category wildcards (image/*) keep their `*`;
// bleve treats `/` and `+` as literals mid-term, so a literal MIME like
// image/svg+xml still matches exactly.
val := n.Value
if searchQuery.FieldIsPath(n.Key) {
val = strings.TrimSuffix(val, "/")
}
k := n.Key
v := val
if k != "ID" && k != "Size" && k != "MimeType" {
v = bleveEscaper.Replace(val)
}
if n.CaseInsensitive {
k += mapping.LowercaseSuffix
v = strings.ToLower(v)
val = strings.ToLower(val)
}
isWildcard := strings.ContainsAny(val, "*?")
// a word-broken field matches the value as a phrase of its words on the
// _words sibling (a quoted query string term is a match phrase query
// run through the field's analyzer); wildcards stay on _lowercase.
// A fulltext field is its own words field, the phrase runs on it.
if searchQuery.FieldIsWordBroken(n.Key) && !isWildcard && !n.Exact {
k, v = n.Key+mapping.WordsSuffix, `"`+strings.ReplaceAll(val, `"`, `\"`)+`"`
} else if searchQuery.FieldIsFulltext(n.Key) && !isWildcard && !n.Exact {
v = `"` + strings.ReplaceAll(val, `"`, `\"`) + `"`
}
var q bleveQuery.Query = bleveQuery.NewQueryStringQuery(k + ":" + v)
switch {
case n.Exact && !isWildcard:
// = matches the whole value, on the lowercased sibling for
// case-insensitive fields
tq := bleveQuery.NewTermQuery(val)
tq.SetField(k)
q = tq
case isWildcard && searchQuery.FieldIsWordBroken(n.Key) && !strings.HasSuffix(val, "*"):
// a wildcard on a word-broken field forgives a missing extension:
// *report also matches Report.txt
bq := bleve.NewBooleanQuery()
bq.AddShould(
bleveQuery.NewQueryStringQuery(k+":"+v),
bleveQuery.NewQueryStringQuery(k+":"+v+".*"),
)
bq.SetMinShould(1)
q = bq
}
if searchQuery.FieldIsPath(n.Key) {
// bleve has no path hierarchy analyzer, unlike OpenSearch: match the
// folder itself and its descendants (`\/*`). A BooleanQuery keeps
// this atomic; a DisjunctionQuery would be redistributed by an
// enclosing AND (mapBinary treats a left disjunction as an OR-chain).
bq := bleve.NewBooleanQuery()
bq.AddShould(q, bleveQuery.NewQueryStringQuery(k+":"+v+`\/*`))
bq.SetMinShould(1)
q = bq
}
if prev == nil {
@@ -161,7 +162,7 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
End: bleveQuery.BleveQueryTime{},
InclusiveStart: nil,
InclusiveEnd: nil,
FieldVal: getField(n.Key),
FieldVal: n.Key,
}
if n.Operator == nil {
@@ -191,7 +192,14 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
next = q
}
case *ast.NumberNode:
q := numberRange(getField(n.Key), n.Operator, n.Value)
var q bleveQuery.Query
if field := n.Key; slices.Contains([]string{"Size", "Type"}, field) {
q = numberRange(field, n.Operator, n.Value)
} else {
// same answer as the OpenSearch backend: unknown numeric keys
// match nothing instead of querying an arbitrary field
q = bleveQuery.NewMatchNoneQuery()
}
if q == nil {
continue
}
@@ -203,16 +211,14 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
}
case *ast.BooleanNode:
q := bleveQuery.NewBoolFieldQuery(n.Value)
q.SetField(getField(n.Key))
q.SetField(n.Key)
if prev == nil {
prev = q
} else {
next = q
}
case *ast.GroupNode:
if n.Key != "" {
n = normalizeGroupingProperty(n)
}
// keys resolved and grouping property propagated in normalize
q, _, err := walk(0, n.Nodes)
if err != nil {
return nil, 0, err
@@ -235,8 +241,11 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
q := bleve.NewBooleanQuery()
q.AddMustNot(next)
if prev == nil {
// unary in the beginning
// unary at the beginning: the term was consumed into the
// MustNot via nextNode, so clear next, otherwise a following
// operator would bind the stale term (NOT x AND y drops y).
prev = q
next = nil
} else {
next = q
}
@@ -260,10 +269,7 @@ 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)
}
// keys are resolved and group keys propagated by normalize
gq, _, err := walk(0, n.Nodes)
if err != nil {
return nil, 0, err
@@ -350,125 +356,3 @@ func numberRange(field string, operator *ast.OperatorNode, value float64) bleveQ
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"
}
if _, ok := _fields[strings.ToLower(name)]; ok {
return _fields[strings.ToLower(name)]
}
return name
}
func normalizeGroupingProperty(group *ast.GroupNode) *ast.GroupNode {
for _, n := range group.Nodes {
if onode, ok := n.(*ast.StringNode); ok {
onode.Key = group.Key
}
}
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":
q := bleve.NewBooleanQuery()
q.AddMustNot(bleveQuery.NewQueryStringQuery(k + ":httpd/unix-directory"))
return q, false
case "folder":
return bleveQuery.NewQueryStringQuery(k + ":httpd/unix-directory"), false
case "document":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
"application/vnd.oasis.opendocument.text",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.apple.pages",
)), true
case "spreadsheet":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/vnd.ms-excel",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.apple.numbers",
)), true
case "presentation":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.ms-powerpoint",
"application/vnd.apple.keynote",
)), true
case "pdf":
return bleveQuery.NewQueryStringQuery(k + ":application/pdf"), false
case "image":
return bleveQuery.NewQueryStringQuery(k + ":image/*"), false
case "video":
return bleveQuery.NewQueryStringQuery(k + ":video/*"), false
case "audio":
return bleveQuery.NewQueryStringQuery(k + ":audio/*"), false
case "archive":
return bleveQuery.NewDisjunctionQuery(newQueryStringQueryList(k,
"application/zip",
"application/gzip",
"application/x-gzip",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-bzip2",
"application/x-bzip",
"application/x-tgz",
)), true
default:
return bleveQuery.NewQueryStringQuery(k + ":" + v), false
}
}
func newQueryStringQueryList(k string, v ...string) []bleveQuery.Query {
list := make([]bleveQuery.Query, len(v))
for i := 0; i < len(v); i++ {
list[i] = bleveQuery.NewQueryStringQuery(k + ":" + v[i])
}
return list
}
@@ -1,12 +1,12 @@
package bleve
import (
"strings"
"testing"
"time"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/opencloud-eu/opencloud/pkg/ast"
searchquery "github.com/opencloud-eu/opencloud/services/search/pkg/query"
tAssert "github.com/stretchr/testify/assert"
)
@@ -19,22 +19,11 @@ 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
}
// TODO(followup): make this a pure compiler test. Field resolution and
// media-type expansion live in query.Normalize, so this test could feed
// canonical ASTs (real field names, media-type already expanded) and call
// compile() directly, dropping the query.Normalize wrapper and the mediatype
// cases.
func boolFieldQuery(field string, value bool) query.Query {
q := query.NewBoolFieldQuery(value)
q.SetField(field)
@@ -57,10 +46,31 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
phraseQuery("Name", "federated"),
query.NewQueryStringQuery(`Name_words:"federated"`),
}),
wantErr: false,
},
{
// path fields expand to match the folder itself and its descendants,
// since bleve has no path hierarchy analyzer.
name: `path:/Foo`,
args: &ast.Ast{
Nodes: []ast.Node{
&ast.StringNode{Key: "path", Value: "/Foo"},
},
},
// a BooleanQuery (should: exact OR descendants), not a DisjunctionQuery,
// so an enclosing AND does not redistribute the folder-itself clause.
want: func() query.Query {
bq := query.NewBooleanQuery(nil, []query.Query{
query.NewQueryStringQuery(`Path:\/Foo`),
query.NewQueryStringQuery(`Path:\/Foo\/*`),
}, nil)
bq.SetMinShould(1)
return query.NewConjunctionQuery([]query.Query{bq})
}(),
wantErr: false,
},
{
name: `"John Smith"`,
args: &ast.Ast{
@@ -69,7 +79,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
phraseQuery("Name", "John Smith"),
query.NewQueryStringQuery(`Name_words:"john smith"`),
}),
wantErr: false,
},
@@ -83,8 +93,8 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
phraseQuery("Name", "John Smith"),
phraseQuery("Name", "Jane"),
query.NewQueryStringQuery(`Name_words:"john smith"`),
query.NewQueryStringQuery(`Name_words:"jane"`),
}),
wantErr: false,
},
@@ -98,8 +108,8 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags:book`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
query.NewQueryStringQuery(`Tags_lowercase:book`),
}),
wantErr: false,
},
@@ -115,10 +125,10 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewDisjunctionQuery([]query.Query{
wildcardQuery("Name", `moby\ di*`),
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags:book`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
query.NewQueryStringQuery(`Tags_lowercase:book`),
}),
}),
wantErr: false,
@@ -136,10 +146,10 @@ func Test_compile(t *testing.T) {
},
want: query.NewDisjunctionQuery([]query.Query{
query.NewConjunctionQuery([]query.Query{
phraseQuery("Name", "a"),
phraseQuery("Name", "b"),
query.NewQueryStringQuery(`Name_words:"a"`),
query.NewQueryStringQuery(`Name_words:"b"`),
}),
phraseQuery("Name", "c"),
query.NewQueryStringQuery(`Name_words:"c"`),
}),
wantErr: false,
},
@@ -155,10 +165,10 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewDisjunctionQuery([]query.Query{
phraseQuery("Name", "a"),
query.NewQueryStringQuery(`Name_words:"a"`),
query.NewConjunctionQuery([]query.Query{
phraseQuery("Name", "b"),
phraseQuery("Name", "c"),
query.NewQueryStringQuery(`Name_words:"b"`),
query.NewQueryStringQuery(`Name_words:"c"`),
}),
}),
wantErr: false,
@@ -180,11 +190,11 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
phraseQuery("Name", "a"),
phraseQuery("Name", "b"),
phraseQuery("Name", "c"),
query.NewQueryStringQuery(`Name_words:"a"`),
query.NewQueryStringQuery(`Name_words:"b"`),
query.NewQueryStringQuery(`Name_words:"c"`),
}),
phraseQuery("Name", "d"),
query.NewQueryStringQuery(`Name_words:"d"`),
}),
wantErr: false,
},
@@ -203,10 +213,10 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
wildcardQuery("Name", `moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
query.NewQueryStringQuery(`Tags_lowercase:book`),
}),
wantErr: false,
},
@@ -228,11 +238,11 @@ func Test_compile(t *testing.T) {
},
want: query.NewConjunctionQuery([]query.Query{
query.NewDisjunctionQuery([]query.Query{
wildcardQuery("Name", `moby\ di*`),
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
}),
query.NewQueryStringQuery(`Tags:book`),
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:read`)}),
query.NewQueryStringQuery(`Tags_lowercase:book`),
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags_lowercase:read`)}),
}),
wantErr: false,
},
@@ -251,7 +261,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
phraseQuery("author", "John Smith"),
query.NewQueryStringQuery(`author:John\ Smith`),
query.NewQueryStringQuery(`author:Jane`),
}),
wantErr: false,
@@ -273,9 +283,9 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
phraseQuery("author", "John Smith"),
query.NewQueryStringQuery(`author:John\ Smith`),
query.NewQueryStringQuery(`author:Jane`),
query.NewQueryStringQuery(`Tags:bestseller`),
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
}),
wantErr: false,
},
@@ -317,47 +327,12 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
phraseQuery("Name", "John Smith"),
query.NewQueryStringQuery(`Name_words:"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,
},
{
name: `NOT tag:physik`,
args: &ast.Ast{
@@ -367,7 +342,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:physik`)}),
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags_lowercase:physik`)}),
}),
wantErr: false,
},
@@ -483,7 +458,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
wildcardQuery("Name", `*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
wantErr: false,
},
@@ -509,7 +484,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewConjunctionQuery([]query.Query{
query.NewQueryStringQuery(`MimeType:application/pdf`),
wildcardQuery("Name", `*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
}),
wantErr: false,
@@ -539,7 +514,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
query.NewQueryStringQuery(`MimeType:application/pdf`),
}),
wildcardQuery("Name", `*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
wantErr: false,
},
@@ -568,7 +543,7 @@ func Test_compile(t *testing.T) {
query.NewQueryStringQuery(`MimeType:application/rtf`),
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
}),
wildcardQuery("Name", `*tdd*`),
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
}),
wantErr: false,
},
@@ -580,7 +555,7 @@ func Test_compile(t *testing.T) {
},
},
want: query.NewConjunctionQuery([]query.Query{
phraseQuery("Name", "John Smith +-=&|><!(){}[]^\"~: "),
query.NewQueryStringQuery(`Name_words:"john smith +-=&|><!(){}[]^\"~: "`),
}),
wantErr: false,
},
@@ -590,7 +565,7 @@ func Test_compile(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := compile(tt.args)
got, err := compile(searchquery.Normalize(tt.args, searchquery.ResolveField))
if (err != nil) != tt.wantErr {
t.Errorf("compile() error = %v, wantErr %v", err, tt.wantErr)
@@ -0,0 +1,102 @@
// Package mimetype maps the "mediatype" KQL restriction (field name and value)
// to a concrete MimeType query.
package mimetype
import (
"strings"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
)
// field is the real index field a mediatype restriction targets.
const field = "MimeType"
// Expand turns mediatype:<value> into the MimeType query it stands for: category
// values (file/document/image/...) expand to their MIME set, anything else is a
// literal MimeType:<value>. Returns nil for non-mediatype keys. Categories and
// MIME types are case-insensitive, so the value is lowercased.
func Expand(key, value string) []ast.Node {
if strings.ToLower(key) != "mediatype" {
return nil
}
value = strings.ToLower(value)
switch value {
case "file":
// grouped so the negation stays atomic when it composes with other
// terms (mediatype:file OR ...)
return []ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: kql.BoolNOT},
&ast.StringNode{Key: field, Value: "httpd/unix-directory"},
}},
}
case "folder":
return term("httpd/unix-directory")
case "document":
return group(
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
"application/vnd.oasis.opendocument.text",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.apple.pages",
)
case "spreadsheet":
return group(
"application/vnd.ms-excel",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.apple.numbers",
)
case "presentation":
return group(
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.ms-powerpoint",
"application/vnd.apple.keynote",
)
case "pdf":
return term("application/pdf")
case "image":
return term("image/*")
case "video":
return term("video/*")
case "audio":
return term("audio/*")
case "archive":
return group(
"application/zip",
"application/gzip",
"application/x-gzip",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-bzip2",
"application/x-bzip",
"application/x-tgz",
)
}
// not a category: treat the value as a literal MIME type.
return term(value)
}
// term is a single MimeType:value restriction.
func term(value string) []ast.Node {
return []ast.Node{&ast.StringNode{Key: field, Value: value}}
}
// group is a single OR group of MimeType:value restrictions.
func group(values ...string) []ast.Node {
nodes := make([]ast.Node, 0, len(values)*2-1)
for i, v := range values {
if i > 0 {
nodes = append(nodes, &ast.OperatorNode{Value: kql.BoolOR})
}
nodes = append(nodes, &ast.StringNode{Key: field, Value: v})
}
return []ast.Node{&ast.GroupNode{Nodes: nodes}}
}
@@ -0,0 +1,13 @@
package mimetype_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMimetype(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Mimetype Suite")
}
@@ -0,0 +1,110 @@
package mimetype_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query/mimetype"
)
// This is the single place the mediatype -> MimeType mapping is tested. The
// query pipeline consumes Expand via query.Normalize and must NOT re-test it.
// mimeValues extracts the StringNode values from an OR group, dropping operators.
func mimeValues(group *ast.GroupNode) []string {
var out []string
for _, n := range group.Nodes {
if s, ok := n.(*ast.StringNode); ok {
out = append(out, s.Value)
}
}
return out
}
var _ = Describe("Expand", func() {
It("only triggers on the mediatype key", func() {
Expect(mimetype.Expand("Name", "document")).To(BeNil())
Expect(mimetype.Expand("MimeType", "file")).To(BeNil()) // the real field name is not the trigger
Expect(mimetype.Expand("Tags", "file")).To(BeNil())
})
It("matches the key case-insensitively", func() {
Expect(mimetype.Expand("MediaType", "file")).ToNot(BeNil())
})
It("matches the value case-insensitively", func() {
// a category matches regardless of case
Expect(mimetype.Expand("mediatype", "Folder")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}))
// a literal MIME type is lowercased too (MIME types are case-insensitive)
Expect(mimetype.Expand("mediatype", "Image/SVG+XML")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "image/svg+xml"},
}))
})
// A non-category value is a literal MIME type and targets the MimeType field.
It("passes literal values through to MimeType", func() {
Expect(mimetype.Expand("mediatype", "application/pdf")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "application/pdf"},
}))
Expect(mimetype.Expand("mediatype", "image/jpeg")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "image/jpeg"},
}))
})
It("expands file to not-a-folder", func() {
Expect(mimetype.Expand("mediatype", "file")).To(Equal([]ast.Node{
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}},
}))
})
It("expands folder to a single term", func() {
Expect(mimetype.Expand("mediatype", "folder")).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}))
})
It("expands wildcard categories", func() {
for value, mime := range map[string]string{
"image": "image/*", "video": "video/*", "audio": "audio/*", "pdf": "application/pdf",
} {
Expect(mimetype.Expand("mediatype", value)).To(Equal([]ast.Node{
&ast.StringNode{Key: "MimeType", Value: mime},
}), value)
}
})
It("expands the document group", func() {
got := mimetype.Expand("mediatype", "document")
Expect(got).To(HaveLen(1))
group, ok := got[0].(*ast.GroupNode)
Expect(ok).To(BeTrue())
Expect(mimeValues(group)).To(Equal([]string{
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
"application/vnd.oasis.opendocument.text",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.apple.pages",
}))
})
// spreadsheet asserts the exact MIME set, in order, with no duplicate entry.
It("expands the spreadsheet group", func() {
group := mimetype.Expand("mediatype", "spreadsheet")[0].(*ast.GroupNode)
Expect(mimeValues(group)).To(Equal([]string{
"application/vnd.ms-excel",
"application/vnd.oasis.opendocument.spreadsheet",
"text/csv",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.apple.numbers",
}))
})
})
+100
View File
@@ -0,0 +1,100 @@
package query
import (
"strconv"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"reflect"
"strings"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query/mimetype"
)
// Normalize is the shared KQL lowering pass between parse and compile: it
// resolves keys to real field names (via resolve) and expands media-type
// restrictions, so the backends compile a plain field:value AST.
func Normalize(a *ast.Ast, resolve func(string) string) *ast.Ast {
a.Nodes = normalizeNodes(a.Nodes, resolve, "")
return a
}
// normalizeNodes rewrites nodes in place. defaultKey is what a bare restriction
// inherits: its enclosing group's key, or "" at the top level.
func normalizeNodes(nodes []ast.Node, resolve func(string) string, defaultKey string) []ast.Node {
resolveKey := func(key string) string {
if key == "" && defaultKey != "" {
return defaultKey // bare child inherits the group key
}
return resolve(key)
}
out := make([]ast.Node, 0, len(nodes))
for _, n := range nodes {
n = toPointer(n) // ensure a pointer so in-place key rewrites persist
switch node := n.(type) {
case *ast.StringNode:
node.Key = resolveKey(node.Key)
if FieldValueIsNormalized(node.Key) {
node.Value = strings.ToLower(node.Value)
}
if node.Key == "Type" {
node.Value = resourceType(node.Value)
}
if exp := mimetype.Expand(node.Key, node.Value); exp != nil {
out = append(out, normalizeNodes(exp, resolve, defaultKey)...)
continue
}
node.CaseInsensitive = FieldIsCaseInsensitive(node.Key)
out = append(out, node)
case *ast.DateTimeNode:
node.Key = resolveKey(node.Key)
out = append(out, node)
case *ast.BooleanNode:
node.Key = resolveKey(node.Key)
out = append(out, node)
case *ast.NumberNode:
node.Key = resolveKey(node.Key)
out = append(out, node)
case *ast.GroupNode:
groupKey := defaultKey
if node.Key != "" {
node.Key = resolve(node.Key)
groupKey = node.Key
}
node.Nodes = normalizeNodes(node.Nodes, resolve, groupKey)
out = append(out, node)
default:
out = append(out, n)
}
}
return out
}
// toPointer returns n as a pointer; the parser emits some nodes by value and the
// in-place key rewrites would be lost on those.
func toPointer(n ast.Node) ast.Node {
rv := reflect.ValueOf(n)
if rv.Kind() == reflect.Ptr {
return n
}
ptr := reflect.New(rv.Type())
ptr.Elem().Set(rv)
if pn, ok := ptr.Interface().(ast.Node); ok {
return pn
}
return n
}
// resourceType maps the type categories to the stored resource type value;
// unknown values pass through and become dead term queries.
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
}
}
+125
View File
@@ -0,0 +1,125 @@
package query_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
// This is the single place the shared KQL lowering pass is tested (field
// resolution, media-type expansion, group-key defaulting, pointer conversion).
// The backend query compilers consume its canonical output and must not re-test
// it.
func norm(nodes ...ast.Node) []ast.Node {
return query.Normalize(&ast.Ast{Nodes: nodes}, query.ResolveField).Nodes
}
var _ = Describe("ResolveField", func() {
It("resolves keys to canonical field names", func() {
Expect(query.ResolveField("")).To(Equal("Name")) // empty -> free-text default
Expect(query.ResolveField("NAME")).To(Equal("Name")) // canonical, case-insensitive key match
Expect(query.ResolveField("tag")).To(Equal("Tags")) // singular alias
Expect(query.ResolveField("mimetype")).To(Equal("MimeType")) // real field
Expect(query.ResolveField("photo.CAMERAMAKE")).To(Equal("photo.cameraMake")) // facet, case-insensitive key match
Expect(query.ResolveField("unknown.field")).To(Equal("unknown.field")) // unknown key: unchanged, becomes a dead query
})
})
var _ = Describe("FieldIsCaseInsensitive", func() {
It("reports the CaseInsensitive override fields", func() {
// keyword fields are case-insensitive by default, facets included
for _, f := range []string{"Name", "Title", "Tags", "audio.artist", "photo.cameraMake"} {
Expect(query.FieldIsCaseInsensitive(f)).To(BeTrue(), f)
}
// opted out (ids, favorites, path, mime type) or not a keyword at all
for _, f := range []string{"MimeType", "ID", "RootID", "ParentID", "Favorites", "Content", "Path", "Size", "unknown"} {
Expect(query.FieldIsCaseInsensitive(f)).To(BeFalse(), f)
}
})
})
var _ = Describe("FieldIsWordBroken", func() {
It("reports the word-broken fields, keywords unless opted out", func() {
for _, f := range []string{"Name", "Title", "audio.artist", "photo.cameraMake"} {
Expect(query.FieldIsWordBroken(f)).To(BeTrue(), f)
}
// opted out (labels, ids, mime type), paths and full text are not
for _, f := range []string{"Tags", "Favorites", "MimeType", "ID", "Content", "Path", "unknown"} {
Expect(query.FieldIsWordBroken(f)).To(BeFalse(), f)
}
})
})
var _ = Describe("Normalize", func() {
It("resolves fields and expands mediatype", func() {
got := norm(
&ast.StringNode{Key: "", Value: "free"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "TAG", Value: "x"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "photo.cameramake", Value: "canon"},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "mediatype", Value: "file"},
&ast.OperatorNode{Value: "AND"},
ast.NumberNode{Key: "size", Value: 100},
)
Expect(got).To(Equal([]ast.Node{
&ast.StringNode{Key: "Name", Value: "free", CaseInsensitive: true},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "Tags", Value: "x", CaseInsensitive: true},
&ast.OperatorNode{Value: "AND"},
&ast.StringNode{Key: "photo.cameraMake", Value: "canon", CaseInsensitive: true},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.OperatorNode{Value: "NOT"},
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.NumberNode{Key: "Size", Value: 100},
}))
})
// A bare restriction inside a named group inherits the group key; a keyed
// child keeps its own key; a bare restriction in an unnamed group falls
// back to Name.
It("defaults group keys", func() {
got := norm(
&ast.GroupNode{Key: "author", Nodes: []ast.Node{
&ast.StringNode{Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "name", Value: "d"},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Value: "e"},
}},
)
Expect(got).To(Equal([]ast.Node{
&ast.GroupNode{Key: "author", Nodes: []ast.Node{
&ast.StringNode{Key: "author", Value: "b"},
&ast.OperatorNode{Value: "OR"},
&ast.StringNode{Key: "Name", Value: "d", CaseInsensitive: true},
}},
&ast.OperatorNode{Value: "AND"},
&ast.GroupNode{Nodes: []ast.Node{
&ast.StringNode{Key: "Name", Value: "e", CaseInsensitive: true},
}},
}))
})
It("converts value nodes to pointers", func() {
got := norm(
ast.StringNode{Key: "name", Value: "x"},
ast.OperatorNode{Value: "AND"},
ast.DateTimeNode{Key: "mtime"},
)
Expect(got).To(Equal([]ast.Node{
&ast.StringNode{Key: "Name", Value: "x", CaseInsensitive: true},
&ast.OperatorNode{Value: "AND"},
&ast.DateTimeNode{Key: "Mtime"},
}))
})
})
@@ -0,0 +1,13 @@
package query_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestQuery(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Query Suite")
}
+103
View File
@@ -0,0 +1,103 @@
package query
import (
"reflect"
"strings"
"sync"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
// aliases are KQL spellings the derived index can't produce (fields are plural).
var aliases = map[string]string{
"tag": "Tags",
"favorite": "Favorites",
}
// fieldIndex maps a lowercased KQL key to its canonical field name ("" is the
// bare-search default).
var fieldIndex = sync.OnceValue(func() map[string]string {
idx := mapping.FieldNameIndex(reflect.TypeFor[search.Resource](), search.Resource{}.SearchFieldOverrides())
for k, v := range aliases {
idx[k] = v
}
idx[""] = idx["name"]
return idx
})
// siblingFields lists which search siblings every field carries, from the
// resource struct and its overrides.
var siblingFields = sync.OnceValue(func() map[string]mapping.Siblings {
return mapping.SearchSiblings(reflect.TypeFor[search.Resource](), search.Resource{}.SearchFieldOverrides())
})
// pathFields are hierarchical path fields (TypePath), derived from the overrides.
var pathFields = sync.OnceValue(func() map[string]struct{} {
out := map[string]struct{}{}
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
if opts.Type == mapping.TypePath {
out[field] = struct{}{}
}
}
return out
})
// fulltextFields are analyzed full-text fields (TypeFulltext), derived from the
// overrides.
var fulltextFields = sync.OnceValue(func() map[string]struct{} {
out := map[string]struct{}{}
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
if opts.Type == mapping.TypeFulltext {
out[field] = struct{}{}
}
}
return out
})
// ResolveField maps a KQL key to its canonical field name; unknown keys pass through.
func ResolveField(name string) string {
if v, ok := fieldIndex()[strings.ToLower(name)]; ok {
return v
}
return name
}
// normalizedValueFields have their stored values normalized to lowercase at
// index time, so query values fold to match even though the fields themselves
// are case-preserved keywords.
var normalizedValueFields = map[string]struct{}{
"MimeType": {},
"Type": {},
"Hidden": {},
}
// FieldValueIsNormalized reports whether a field's stored values are
// normalized lowercase.
func FieldValueIsNormalized(field string) bool {
_, ok := normalizedValueFields[field]
return ok
}
// FieldIsCaseInsensitive reports whether a field's default search is case-insensitive.
func FieldIsCaseInsensitive(field string) bool {
return siblingFields()[field].Lowercase
}
// FieldIsPath reports whether a field is a hierarchical path field.
func FieldIsPath(field string) bool {
_, ok := pathFields()[field]
return ok
}
// FieldIsFulltext reports whether a field is an analyzed full-text field.
func FieldIsFulltext(field string) bool {
_, ok := fulltextFields()[field]
return ok
}
// FieldIsWordBroken reports whether a field is split into words, so a value
// without a wildcard matches it as a phrase of those words instead of as a whole.
func FieldIsWordBroken(field string) bool {
return siblingFields()[field].Words
}
+42 -7
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"regexp"
"strings"
"sync"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
@@ -19,8 +20,15 @@ import (
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
)
// SchemaVersion is the shared schema version for both search backends. Bump it
// on a breaking mapping change: each version gets its own index (OpenSearch name
// suffix, bleve path suffix), so the service builds a fresh index instead of
// colliding with the old one. No migration; reindex to populate.
const SchemaVersion = 4
var scopeRegex = regexp.MustCompile(`scope:\s*([^" "\n\r]*)`)
// Engine is the interface to the search engine
@@ -52,13 +60,40 @@ type BatchOperator interface {
type Resource struct {
content.Document
ID string
RootID string
Path string
ParentID string
Type uint64
Deleted bool
Hidden bool
ID string `json:"ID"`
RootID string `json:"RootID"`
Path string `json:"Path"`
ParentID string `json:"ParentID"`
Type uint64 `json:"Type"`
Deleted bool `json:"Deleted"`
Hidden bool `json:"Hidden"`
}
// resourceFieldOverrides is built once (it never changes) and reused on hot
// paths instead of reallocating per call.
var resourceFieldOverrides = sync.OnceValue(func() map[string]mapping.FieldOpts {
True, False := true, false
return map[string]mapping.FieldOpts{
// every keyword field searches case-insensitively and by word (name,
// title, the facets) unless opted out: ids are opaque, paths are POSIX,
// the mime type is normalized already, a tag is one label
"ID": {CaseInsensitive: &False, NoWordBreaker: &True},
"RootID": {CaseInsensitive: &False, NoWordBreaker: &True},
"ParentID": {CaseInsensitive: &False, NoWordBreaker: &True},
"Path": {Type: mapping.TypePath, CaseInsensitive: &False},
"MimeType": {CaseInsensitive: &False, NoWordBreaker: &True},
"Content": {Type: mapping.TypeFulltext},
"Tags": {NoWordBreaker: &True, IncludeInAll: &False},
"Favorites": {NoWordBreaker: &True, IncludeInAll: &False, CaseInsensitive: &False}, // opaque user ids
"location": {Type: mapping.TypeGeopoint},
}
})
// SearchFieldOverrides returns the field options the mapping package needs to
// build per-backend index mappings for a Resource (keys are json-tag names).
// The map is shared and read-only; clone it before mutating.
func (Resource) SearchFieldOverrides() map[string]mapping.FieldOpts {
return resourceFieldOverrides()
}
// ResolveReference makes sure the path is relative to the space root
+19 -36
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
@@ -666,10 +667,10 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) {
// determine if metadata needs to be stored in storage as well
metadata := map[string]string{}
addAudioMetadata(metadata, doc.Audio)
addImageMetadata(metadata, doc.Image)
addLocationMetadata(metadata, doc.Location)
addPhotoMetadata(metadata, doc.Photo)
facetToMetadata(metadata, doc.Audio, "libre.graph.audio.")
facetToMetadata(metadata, doc.Image, "libre.graph.image.")
facetToMetadata(metadata, doc.Location, "libre.graph.location.")
facetToMetadata(metadata, doc.Photo, "libre.graph.photo.")
if len(metadata) == 0 {
return
}
@@ -705,43 +706,25 @@ func IsHidden(path string) bool {
return false
}
func addAudioMetadata(metadata map[string]string, audio *libregraph.Audio) {
if audio == nil {
return
// facetToMetadata flattens a libregraph facet (Audio / Image / Location / Photo
// pointer) into the metadata map under the given prefix via the model's ToMap.
// No-op when the facet is nil.
func facetToMetadata[T libregraph.MappedNullable](metadata map[string]string, facet T, prefix string) {
// Only nilable kinds can be nil; IsNil panics on a value type (some
// libregraph models satisfy MappedNullable with a value receiver).
switch v := reflect.ValueOf(facet); v.Kind() {
case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Interface, reflect.Chan, reflect.Func:
if v.IsNil() {
return
}
}
marshalToStringMap(audio, metadata, "libre.graph.audio.")
}
func addImageMetadata(metadata map[string]string, image *libregraph.Image) {
if image == nil {
return
}
marshalToStringMap(image, metadata, "libre.graph.image.")
}
func addLocationMetadata(metadata map[string]string, location *libregraph.GeoCoordinates) {
if location == nil {
return
}
marshalToStringMap(location, metadata, "libre.graph.location.")
}
func addPhotoMetadata(metadata map[string]string, photo *libregraph.Photo) {
if photo == nil {
return
}
marshalToStringMap(photo, metadata, "libre.graph.photo.")
}
func marshalToStringMap[T libregraph.MappedNullable](source T, target map[string]string, prefix string) {
// ToMap never returns a non-nil error ...
m, _ := source.ToMap()
// ToMap never returns a non-nil error.
m, _ := facet.ToMap()
for k, v := range m {
if v == nil {
continue
}
target[prefix+k] = valueToString(v)
metadata[prefix+k] = valueToString(v)
}
}
+1 -1
View File
@@ -125,8 +125,8 @@ github.com/blevesearch/bleve/v2/analysis
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/flexible
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