Initial QSfera import
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
|
||||
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
searchQuery "github.com/qsfera/server/services/search/pkg/query"
|
||||
)
|
||||
|
||||
const defaultBatchSize = 50
|
||||
|
||||
var _ search.Engine = (*Backend)(nil) // ensure Backend implements Engine
|
||||
|
||||
type Backend struct {
|
||||
index bleve.Index
|
||||
queryCreator searchQuery.Creator[query.Query]
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func NewBackend(index bleve.Index, queryCreator searchQuery.Creator[query.Query], log log.Logger) *Backend {
|
||||
return &Backend{
|
||||
index: index,
|
||||
queryCreator: queryCreator,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Search executes a search request operation within the index.
|
||||
// Returns a SearchIndexResponse object or an error.
|
||||
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) {
|
||||
return nil, errtypes.BadRequest(err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := bleve.NewConjunctionQuery(
|
||||
// Skip documents that have been marked as deleted
|
||||
&query.BoolFieldQuery{
|
||||
Bool: false,
|
||||
FieldVal: "Deleted",
|
||||
},
|
||||
createdQuery,
|
||||
)
|
||||
|
||||
if sir.Ref != nil {
|
||||
q.Conjuncts = append(
|
||||
q.Conjuncts,
|
||||
&query.TermQuery{
|
||||
FieldVal: "RootID",
|
||||
Term: storagespace.FormatResourceID(
|
||||
&storageProvider.ResourceId{
|
||||
StorageId: sir.Ref.GetResourceId().GetStorageId(),
|
||||
SpaceId: sir.Ref.GetResourceId().GetSpaceId(),
|
||||
OpaqueId: sir.Ref.GetResourceId().GetOpaqueId(),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
bleveReq := bleve.NewSearchRequest(q)
|
||||
bleveReq.Highlight = bleve.NewHighlight()
|
||||
|
||||
switch {
|
||||
case sir.PageSize == -1:
|
||||
bleveReq.Size = math.MaxInt
|
||||
case sir.PageSize == 0:
|
||||
bleveReq.Size = 200
|
||||
default:
|
||||
bleveReq.Size = int(sir.PageSize)
|
||||
}
|
||||
|
||||
bleveReq.Fields = []string{"*"}
|
||||
res, err := b.index.Search(bleveReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
rID, err := storagespace.ParseID(getFieldValue[string](hit.Fields, "ID"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pID, _ := storagespace.ParseID(getFieldValue[string](hit.Fields, "ParentID"))
|
||||
match := &searchMessage.Match{
|
||||
Score: float32(hit.Score),
|
||||
Entity: &searchMessage.Entity{
|
||||
Ref: &searchMessage.Reference{
|
||||
ResourceId: resourceIDtoSearchID(rootID),
|
||||
Path: getFieldValue[string](hit.Fields, "Path"),
|
||||
},
|
||||
Id: resourceIDtoSearchID(rID),
|
||||
Name: getFieldValue[string](hit.Fields, "Name"),
|
||||
ParentId: resourceIDtoSearchID(pID),
|
||||
Size: uint64(getFieldValue[float64](hit.Fields, "Size")),
|
||||
Type: uint64(getFieldValue[float64](hit.Fields, "Type")),
|
||||
MimeType: getFieldValue[string](hit.Fields, "MimeType"),
|
||||
Deleted: getFieldValue[bool](hit.Fields, "Deleted"),
|
||||
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),
|
||||
},
|
||||
}
|
||||
|
||||
if mtime, err := time.Parse(time.RFC3339, getFieldValue[string](hit.Fields, "Mtime")); err == nil {
|
||||
match.Entity.LastModifiedTime = ×tamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
|
||||
}
|
||||
|
||||
matches = append(matches, match)
|
||||
}
|
||||
|
||||
return &searchService.SearchIndexResponse{
|
||||
Matches: matches,
|
||||
TotalMatches: int32(totalMatches),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) DocCount() (uint64, error) {
|
||||
return b.index.DocCount()
|
||||
}
|
||||
|
||||
func (b *Backend) Upsert(id string, r search.Resource) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Upsert(id, r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Move(rootID, parentID, location string) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Move(rootID, parentID, location); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Delete(id string) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Restore(id string) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Restore(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Purge(id string, onlyDeleted bool) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Purge(id, onlyDeleted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) NewBatch(size int) (search.BatchOperator, error) {
|
||||
return NewBatch(b.index, size)
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
package bleve_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/bleve"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
bleveQuery "github.com/qsfera/server/services/search/pkg/query/bleve"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
var _ = Describe("Bleve", func() {
|
||||
var (
|
||||
eng *bleve.Backend
|
||||
idx bleveSearch.Index
|
||||
|
||||
doSearch = func(id string, query, path string) (*searchsvc.SearchIndexResponse, error) {
|
||||
rID, err := storagespace.ParseID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return eng.Search(context.Background(), &searchsvc.SearchIndexRequest{
|
||||
Query: query,
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: rID.StorageId,
|
||||
SpaceId: rID.SpaceId,
|
||||
OpaqueId: rID.OpaqueId,
|
||||
},
|
||||
Path: path,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
assertDocCount = func(id string, query string, expectedCount int) []*searchmsg.Match {
|
||||
res, err := doSearch(id, query, "")
|
||||
|
||||
ExpectWithOffset(1, err).ToNot(HaveOccurred())
|
||||
ExpectWithOffset(1, len(res.Matches)).To(Equal(expectedCount), "query returned unexpected number of results: "+query)
|
||||
return res.Matches
|
||||
}
|
||||
|
||||
rootResource search.Resource
|
||||
parentResource search.Resource
|
||||
childResource search.Resource
|
||||
childResource2 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"},
|
||||
}
|
||||
|
||||
childResource2 = search.Resource{
|
||||
ID: "1$2!5",
|
||||
ParentID: parentResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./parent d!r/child2.pdf",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{Name: "child2.pdf"},
|
||||
}
|
||||
})
|
||||
|
||||
Describe("New", func() {
|
||||
It("returns a new index instance", func() {
|
||||
b := bleve.NewBackend(idx, bleveQuery.DefaultCreator, log.Logger{})
|
||||
Expect(b).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Search", func() {
|
||||
Context("by other fields than filename", func() {
|
||||
It("finds files by tags", func() {
|
||||
parentResource.Document.Tags = []string{"foo", "bar"}
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Tags:foo", 1)
|
||||
assertDocCount(rootResource.ID, "Tags:bar", 1)
|
||||
assertDocCount(rootResource.ID, "Tags:foo Tags:bar", 1)
|
||||
assertDocCount(rootResource.ID, "Tags:foo Tags:bar Tags:baz", 1)
|
||||
assertDocCount(rootResource.ID, "Tags:foo Tags:bar Tags:baz", 1)
|
||||
assertDocCount(rootResource.ID, "Tags:baz", 0)
|
||||
})
|
||||
|
||||
It("finds files by size", func() {
|
||||
parentResource.Document.Size = 12345
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Size:12345", 1)
|
||||
assertDocCount(rootResource.ID, "Size:>1000", 1)
|
||||
assertDocCount(rootResource.ID, "Size:<100000", 1)
|
||||
assertDocCount(rootResource.ID, "Size:12344", 0)
|
||||
assertDocCount(rootResource.ID, "Size:<1000", 0)
|
||||
assertDocCount(rootResource.ID, "Size:>100000", 0)
|
||||
})
|
||||
|
||||
It("preserves value case for fields not explicitly marked lowercase", func() {
|
||||
parentResource.Document.Audio = &libregraph.Audio{
|
||||
Artist: libregraph.PtrString("Some Artist"),
|
||||
}
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `audio.artist:"Some Artist"`, 1)
|
||||
assertDocCount(rootResource.ID, `audio.artist:"some artist"`, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Context("by filename", func() {
|
||||
It("finds files with spaces in the filename", func() {
|
||||
parentResource.Document.Name = "Foo oo.pdf"
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `name:"foo o*"`, 1)
|
||||
})
|
||||
|
||||
It("finds files by digits in the filename", func() {
|
||||
parentResource.Document.Name = "12345.pdf"
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Name:1234*", 1)
|
||||
})
|
||||
|
||||
It("filters hidden files", func() {
|
||||
childResource.Hidden = true
|
||||
err := eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Hidden:T", 1)
|
||||
assertDocCount(rootResource.ID, "Hidden:F", 0)
|
||||
})
|
||||
|
||||
Context("with a file in the root of the space", func() {
|
||||
It("scopes the search to the specified space", func() {
|
||||
parentResource.Document.Name = "foo.pdf"
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Name:foo.pdf", 1)
|
||||
assertDocCount("9$8!7", "Name:foo.pdf", 0)
|
||||
})
|
||||
})
|
||||
|
||||
It("limits the search to the specified fields", func() {
|
||||
parentResource.Document.Name = "bar.pdf"
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Name:bar.pdf", 1)
|
||||
assertDocCount(rootResource.ID, "Unknown:field", 0)
|
||||
})
|
||||
|
||||
It("returns the total number of hits", func() {
|
||||
parentResource.Document.Name = "bar.pdf"
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res, err := doSearch(rootResource.ID, "Name:bar*", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(1)))
|
||||
})
|
||||
|
||||
It("returns all desired fields", func() {
|
||||
parentResource.Document.Name = "bar.pdf"
|
||||
parentResource.Type = 3
|
||||
parentResource.MimeType = "application/pdf"
|
||||
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
matches := assertDocCount(rootResource.ID, fmt.Sprintf("Name:%s", parentResource.Name), 1)
|
||||
match := matches[0]
|
||||
Expect(match.Entity.Ref.Path).To(Equal(parentResource.Path))
|
||||
Expect(match.Entity.Name).To(Equal(parentResource.Name))
|
||||
Expect(match.Entity.Size).To(Equal(parentResource.Size))
|
||||
Expect(match.Entity.Type).To(Equal(parentResource.Type))
|
||||
Expect(match.Entity.MimeType).To(Equal(parentResource.MimeType))
|
||||
Expect(match.Entity.Deleted).To(BeFalse())
|
||||
Expect(match.Score > 0).To(BeTrue())
|
||||
})
|
||||
|
||||
It("finds files by name, prefix or substring match", func() {
|
||||
parentResource.Document.Name = "foo.pdf"
|
||||
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
queries := []string{"foo.pdf", "foo*", "*oo.p*"}
|
||||
for _, query := range queries {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, query, 1)
|
||||
}
|
||||
})
|
||||
|
||||
It("does a case-insensitive search", func() {
|
||||
parentResource.Document.Name = "foo.pdf"
|
||||
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Name:foo*", 1)
|
||||
assertDocCount(rootResource.ID, "Name:Foo*", 1)
|
||||
})
|
||||
|
||||
Context("and an additional file in a subdirectory", func() {
|
||||
BeforeEach(func() {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("finds files living deeper in the tree by filename, prefix or substring match", func() {
|
||||
queries := []string{"child.pdf", "child*", "*ld.*"}
|
||||
for _, query := range queries {
|
||||
assertDocCount(rootResource.ID, query, 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("Highlights", func() {
|
||||
|
||||
It("highlights only for content searches", func() {
|
||||
parentResource.Document.Name = "baz.pdf"
|
||||
parentResource.Document.Content = "foo bar baz"
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res, err := doSearch(rootResource.ID, "Name:baz*", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(1)))
|
||||
Expect(res.Matches[0].Entity.Highlights).To(Equal(""))
|
||||
})
|
||||
|
||||
It("highlights search terms", func() {
|
||||
parentResource.Document.Name = "baz.pdf"
|
||||
parentResource.Document.Content = "foo bar baz"
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res, err := doSearch(rootResource.ID, "Content:bar", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(1)))
|
||||
Expect(res.Matches[0].Entity.Highlights).To(Equal("foo <mark>bar</mark> baz"))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Context("with a file in the root of the space and folder with a file. all of them have the same name", func() {
|
||||
BeforeEach(func() {
|
||||
parentResource := search.Resource{
|
||||
ID: "1$2!3",
|
||||
ParentID: rootResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./doc",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_CONTAINER),
|
||||
Document: content.Document{Name: "doc"},
|
||||
}
|
||||
|
||||
childResource := search.Resource{
|
||||
ID: "1$2!4",
|
||||
ParentID: parentResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./doc/doc.pdf",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{Name: "doc.pdf"},
|
||||
}
|
||||
|
||||
childResource2 := search.Resource{
|
||||
ID: "1$2!7",
|
||||
ParentID: parentResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./doc/file.pdf",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{Name: "file.pdf"},
|
||||
}
|
||||
|
||||
rootChildResource := search.Resource{
|
||||
ID: "1$2!5",
|
||||
ParentID: rootResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./doc.pdf",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{Name: "doc.pdf"},
|
||||
}
|
||||
|
||||
rootChildResource2 := search.Resource{
|
||||
ID: "1$2!6",
|
||||
ParentID: rootResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./file.pdf",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{Name: "file.pdf"},
|
||||
}
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(rootChildResource.ID, rootChildResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = eng.Upsert(rootChildResource2.ID, rootChildResource2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = eng.Upsert(childResource2.ID, childResource2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
It("search *doc* in a root", func() {
|
||||
res, err := doSearch(rootResource.ID, "Name:*doc*", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(3)))
|
||||
})
|
||||
It("search *doc* in a subfolder", func() {
|
||||
res, err := doSearch(rootResource.ID, "Name:*doc*", "./doc")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(2)))
|
||||
})
|
||||
It("search *file* in a root", func() {
|
||||
res, err := doSearch(rootResource.ID, "Name:*file*", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(2)))
|
||||
})
|
||||
It("search *file* in a subfolder", func() {
|
||||
res, err := doSearch(rootResource.ID, "Name:*file*", "./doc")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(1)))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("Upsert", func() {
|
||||
It("adds a resourceInfo to the index", func() {
|
||||
err := eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(uint64(1)))
|
||||
|
||||
query := bleveSearch.NewMatchQuery("child.pdf")
|
||||
res, err := idx.Search(bleveSearch.NewSearchRequest(query))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits.Len()).To(Equal(1))
|
||||
})
|
||||
|
||||
It("updates an existing resource in the index", func() {
|
||||
|
||||
err := eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
countA, err := idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(countA).To(Equal(uint64(1)))
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
countB, err := idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(countB).To(Equal(uint64(1)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("marks a resource as deleted", func() {
|
||||
err := eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Name:*child*", 1)
|
||||
|
||||
err = eng.Delete(childResource.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, "Name:*child*", 0)
|
||||
})
|
||||
|
||||
It("marks a child resources as deleted", func() {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
|
||||
|
||||
err = eng.Delete(parentResource.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Restore", func() {
|
||||
It("also marks child resources as restored", func() {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Delete(parentResource.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Name+`"`, 0)
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Name+`"`, 0)
|
||||
|
||||
err = eng.Restore(parentResource.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Name+`"`, 1)
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Name+`"`, 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Purge", func() {
|
||||
It("removes a resource from the index", func() {
|
||||
err := eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
assertDocCount(rootResource.ID, "Name:child.pdf", 1)
|
||||
|
||||
err = eng.Purge(childResource.ID, false)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
assertDocCount(rootResource.ID, "Name:child.pdf", 0)
|
||||
})
|
||||
It("removes a resource and its children from the index", func() {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
|
||||
|
||||
err = eng.Purge(parentResource.ID, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 0)
|
||||
})
|
||||
It("removes a resource and ignores its children from the index", func() {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 1)
|
||||
|
||||
err = eng.Delete(parentResource.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
|
||||
|
||||
err = eng.Purge(parentResource.ID, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, `"`+parentResource.Document.Name+`"`, 0)
|
||||
assertDocCount(rootResource.ID, `"`+childResource.Document.Name+`"`, 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Move", func() {
|
||||
It("renames the parent and its child resources", func() {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
parentResource.Path = "newname"
|
||||
err = eng.Move(parentResource.ID, parentResource.ParentID, "./my/newname")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
assertDocCount(rootResource.ID, parentResource.Name, 0)
|
||||
|
||||
matches := assertDocCount(rootResource.ID, "Name:child.pdf", 1)
|
||||
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("3"))
|
||||
Expect(matches[0].Entity.Ref.Path).To(Equal("./my/newname/child.pdf"))
|
||||
})
|
||||
|
||||
It("moves the parent and its child resources", func() {
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = eng.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
parentResource.Path = " "
|
||||
parentResource.ParentID = "1$2!somewhereopaqueid"
|
||||
|
||||
err = eng.Move(parentResource.ID, parentResource.ParentID, "./somewhere/else/newname")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
assertDocCount(rootResource.ID, `parent d!r`, 0)
|
||||
|
||||
matches := assertDocCount(rootResource.ID, "Name:child.pdf", 1)
|
||||
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("3"))
|
||||
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname/child.pdf"))
|
||||
|
||||
matches = assertDocCount(rootResource.ID, `newname`, 1)
|
||||
Expect(matches[0].Entity.ParentId.OpaqueId).To(Equal("somewhereopaqueid"))
|
||||
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname"))
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
Describe("StartBatch", func() {
|
||||
It("starts a new batch", func() {
|
||||
b, err := eng.NewBatch(100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = b.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(uint64(0)))
|
||||
|
||||
err = b.Push()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err = idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(uint64(1)))
|
||||
|
||||
query := bleveSearch.NewMatchQuery("child.pdf")
|
||||
res, err := idx.Search(bleveSearch.NewSearchRequest(query))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits.Len()).To(Equal(1))
|
||||
})
|
||||
|
||||
It("doesn't intertwine different batches", func() {
|
||||
b, err := eng.NewBatch(100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = b.Upsert(childResource.ID, childResource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(uint64(0)))
|
||||
|
||||
b2, err := eng.NewBatch(100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = b2.Upsert(childResource2.ID, childResource2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(b.Push()).To(Succeed())
|
||||
count, err = idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(uint64(1)))
|
||||
|
||||
Expect(b2.Push()).To(Succeed())
|
||||
count, err = idx.DocCount()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(uint64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("File type specific metadata", func() {
|
||||
|
||||
Context("with audio metadata", func() {
|
||||
BeforeEach(func() {
|
||||
resource := search.Resource{
|
||||
ID: "1$2!7",
|
||||
ParentID: rootResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./some_song.mp3",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{
|
||||
Name: "some_song.mp3",
|
||||
MimeType: "audio/mpeg",
|
||||
Audio: &libregraph.Audio{
|
||||
Album: libregraph.PtrString("Some Album"),
|
||||
AlbumArtist: libregraph.PtrString("Some AlbumArtist"),
|
||||
Artist: libregraph.PtrString("Some Artist"),
|
||||
Bitrate: libregraph.PtrInt64(192),
|
||||
Composers: libregraph.PtrString("Some Composers"),
|
||||
Copyright: libregraph.PtrString(""),
|
||||
Disc: libregraph.PtrInt32(2),
|
||||
DiscCount: libregraph.PtrInt32(5),
|
||||
Duration: libregraph.PtrInt64(225000),
|
||||
Genre: libregraph.PtrString("Some Genre"),
|
||||
HasDrm: libregraph.PtrBool(false),
|
||||
IsVariableBitrate: libregraph.PtrBool(true),
|
||||
Title: libregraph.PtrString("Some Title"),
|
||||
Track: libregraph.PtrInt32(34),
|
||||
TrackCount: libregraph.PtrInt32(99),
|
||||
Year: libregraph.PtrInt32(2004),
|
||||
},
|
||||
},
|
||||
}
|
||||
err := eng.Upsert(resource.ID, resource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns audio metadata for search", func() {
|
||||
matches := assertDocCount(rootResource.ID, `*song*`, 1)
|
||||
audio := matches[0].Entity.Audio
|
||||
|
||||
Expect(audio).ToNot(BeNil())
|
||||
|
||||
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
|
||||
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
|
||||
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
|
||||
Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
|
||||
Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
|
||||
Expect(audio.Copyright).To(Equal(libregraph.PtrString("")))
|
||||
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(2)))
|
||||
Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
|
||||
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225000)))
|
||||
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
|
||||
Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
|
||||
Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
|
||||
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
|
||||
Expect(audio.Track).To(Equal(libregraph.PtrInt32(34)))
|
||||
Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(99)))
|
||||
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with location metadata", func() {
|
||||
BeforeEach(func() {
|
||||
resource := search.Resource{
|
||||
ID: "1$2!7",
|
||||
ParentID: rootResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./team.jpg",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{
|
||||
Name: "team.jpg",
|
||||
MimeType: "image/jpeg",
|
||||
Location: &libregraph.GeoCoordinates{
|
||||
Altitude: libregraph.PtrFloat64(1047.7),
|
||||
Latitude: libregraph.PtrFloat64(49.48675890884328),
|
||||
Longitude: libregraph.PtrFloat64(11.103870357204285),
|
||||
},
|
||||
},
|
||||
}
|
||||
err := eng.Upsert(resource.ID, resource)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns audio metadata for search", func() {
|
||||
matches := assertDocCount(rootResource.ID, `*team*`, 1)
|
||||
location := matches[0].Entity.Location
|
||||
|
||||
Expect(location).ToNot(BeNil())
|
||||
|
||||
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(1047.7)))
|
||||
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
|
||||
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
var _ search.BatchOperator = (*Batch)(nil) // ensure Batch implements BatchOperator
|
||||
|
||||
type Batch struct {
|
||||
batch *bleve.Batch
|
||||
index bleve.Index
|
||||
size int
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func NewBatch(index bleve.Index, size int) (*Batch, error) {
|
||||
if size <= 0 {
|
||||
return nil, errors.New("batch size must be greater than 0")
|
||||
}
|
||||
|
||||
return &Batch{
|
||||
batch: index.NewBatch(),
|
||||
index: index,
|
||||
size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Batch) Upsert(id string, r search.Resource) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
return b.batch.Index(id, r)
|
||||
})
|
||||
}
|
||||
|
||||
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(location)
|
||||
|
||||
rootResource.Path = nextPath
|
||||
rootResource.Name = path.Base(nextPath)
|
||||
rootResource.ParentID = parentID
|
||||
|
||||
resources := []*search.Resource{rootResource}
|
||||
|
||||
if rootResource.Type == uint64(storageProvider.ResourceType_RESOURCE_TYPE_CONTAINER) {
|
||||
descendantResources, err := searchResourcesByPath(rootResource.RootID, currentPath, b.index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, descendantResource := range descendantResources {
|
||||
descendantResource.Path = strings.Replace(descendantResource.Path, currentPath, nextPath, 1)
|
||||
resources = append(resources, descendantResource)
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
if err := b.batch.Index(resource.ID, resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.batch.Size() >= b.size {
|
||||
if err := b.Push(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Delete(id string) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
affectedResources, err := searchAndUpdateResourcesDeletionState(id, true, b.index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, resource := range affectedResources {
|
||||
if err := b.batch.Index(resource.ID, resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.batch.Size() >= b.size {
|
||||
if err := b.Push(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Restore(id string) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
affectedResources, err := searchAndUpdateResourcesDeletionState(id, false, b.index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, resource := range affectedResources {
|
||||
if err := b.batch.Index(resource.ID, resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.batch.Size() >= b.size {
|
||||
if err := b.Push(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Purge(id string, onlyDeleted bool) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
rootResource, err := searchResourceByID(id, b.index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var affectResources []*search.Resource
|
||||
add := func(resource *search.Resource) {
|
||||
if onlyDeleted && !resource.Deleted {
|
||||
return
|
||||
}
|
||||
|
||||
affectResources = append(affectResources, resource)
|
||||
}
|
||||
|
||||
add(rootResource)
|
||||
|
||||
if rootResource.Type == uint64(storageProvider.ResourceType_RESOURCE_TYPE_CONTAINER) {
|
||||
descendantResources, err := searchResourcesByPath(rootResource.RootID, rootResource.Path, b.index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, descendantResource := range descendantResources {
|
||||
add(descendantResource)
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range affectResources {
|
||||
b.batch.Delete(resource.ID)
|
||||
if b.batch.Size() >= b.size {
|
||||
if err := b.Push(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Push() error {
|
||||
if b.batch.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := b.index.Batch(b.batch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.batch.Reset()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Batch) withSizeLimit(f func() error) error {
|
||||
if err := f(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.batch.Size() >= b.size {
|
||||
return b.Push()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
var queryEscape = regexp.MustCompile(`([` + regexp.QuoteMeta(`+=&|><!(){}[]^\"~*?:\/`) + `\-\s])`)
|
||||
|
||||
func getFieldValue[T any](m map[string]any, key string) (out T) {
|
||||
val, ok := m[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out, _ = val.(T)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func resourceIDtoSearchID(id storageProvider.ResourceId) *searchMessage.ResourceID {
|
||||
return &searchMessage.ResourceID{
|
||||
StorageId: id.GetStorageId(),
|
||||
SpaceId: id.GetSpaceId(),
|
||||
OpaqueId: id.GetOpaqueId()}
|
||||
}
|
||||
|
||||
func getFieldSliceValue[T any](m map[string]any, key string) (out []T) {
|
||||
iv := getFieldValue[any](m, key)
|
||||
add := func(v any) {
|
||||
cv, ok := v.(T)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
out = append(out, cv)
|
||||
}
|
||||
|
||||
// bleve tend to convert []string{"foo"} to type string if slice contains only one value
|
||||
// bleve: []string{"foo"} -> "foo"
|
||||
// bleve: []string{"foo", "bar"} -> []string{"foo", "bar"}
|
||||
switch v := iv.(type) {
|
||||
case T:
|
||||
add(v)
|
||||
case []any:
|
||||
for _, rv := range v {
|
||||
add(rv)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func getFragmentValue(m bleveSearch.FieldFragmentMap, key string, idx int) string {
|
||||
val, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(val) <= idx {
|
||||
return ""
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
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"),
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func escapeQuery(s string) string {
|
||||
return queryEscape.ReplaceAllString(s, "\\$1")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package bleve_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestEngine(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Bleve Suite")
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/porter"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/single"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
func NewIndex(root string) (bleve.Index, error) {
|
||||
destination := filepath.Join(root, "bleve")
|
||||
index, err := bleve.Open(destination)
|
||||
if errors.Is(bleve.ErrorIndexPathDoesNotExist, err) {
|
||||
indexMapping, err := NewMapping()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index, err = bleve.New(destination, indexMapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return index, nil
|
||||
}
|
||||
|
||||
return index, err
|
||||
}
|
||||
|
||||
func NewMapping() (mapping.IndexMapping, error) {
|
||||
nameMapping := bleve.NewTextFieldMapping()
|
||||
nameMapping.Analyzer = "lowercaseKeyword"
|
||||
|
||||
lowercaseMapping := bleve.NewTextFieldMapping()
|
||||
lowercaseMapping.IncludeInAll = false
|
||||
lowercaseMapping.Analyzer = "lowercaseKeyword"
|
||||
|
||||
fulltextFieldMapping := bleve.NewTextFieldMapping()
|
||||
fulltextFieldMapping.Analyzer = "fulltext"
|
||||
fulltextFieldMapping.IncludeInAll = false
|
||||
|
||||
docMapping := bleve.NewDocumentMapping()
|
||||
docMapping.AddFieldMappingsAt("Name", nameMapping)
|
||||
docMapping.AddFieldMappingsAt("Tags", lowercaseMapping)
|
||||
docMapping.AddFieldMappingsAt("Favorites", lowercaseMapping)
|
||||
docMapping.AddFieldMappingsAt("Content", fulltextFieldMapping)
|
||||
|
||||
indexMapping := bleve.NewIndexMapping()
|
||||
indexMapping.DefaultAnalyzer = keyword.Name
|
||||
indexMapping.DefaultMapping = docMapping
|
||||
err := indexMapping.AddCustomAnalyzer("lowercaseKeyword",
|
||||
map[string]any{
|
||||
"type": custom.Name,
|
||||
"tokenizer": single.Name,
|
||||
"token_filters": []string{
|
||||
lowercase.Name,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = indexMapping.AddCustomAnalyzer("fulltext",
|
||||
map[string]any{
|
||||
"type": custom.Name,
|
||||
"tokenizer": unicode.Name,
|
||||
"token_filters": []string{
|
||||
lowercase.Name,
|
||||
porter.Name,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return indexMapping, nil
|
||||
}
|
||||
|
||||
func searchResourceByID(id string, index bleve.Index) (*search.Resource, error) {
|
||||
req := bleve.NewSearchRequest(bleve.NewDocIDQuery([]string{id}))
|
||||
req.Fields = []string{"*"}
|
||||
res, err := index.Search(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Hits.Len() == 0 {
|
||||
return nil, errors.New("entity not found")
|
||||
}
|
||||
|
||||
return matchToResource(res.Hits[0]), nil
|
||||
}
|
||||
|
||||
func searchResourcesByPath(rootId, lookupPath string, index bleve.Index) ([]*search.Resource, error) {
|
||||
q := bleve.NewConjunctionQuery(
|
||||
bleve.NewQueryStringQuery("RootID:"+rootId),
|
||||
bleve.NewQueryStringQuery("Path:"+escapeQuery(lookupPath+"/*")),
|
||||
)
|
||||
bleveReq := bleve.NewSearchRequest(q)
|
||||
bleveReq.Size = math.MaxInt
|
||||
bleveReq.Fields = []string{"*"}
|
||||
res, err := index.Search(bleveReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resources := make([]*search.Resource, 0, res.Hits.Len())
|
||||
for _, match := range res.Hits {
|
||||
resources = append(resources, matchToResource(match))
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
func searchAndUpdateResourcesDeletionState(id string, state bool, index bleve.Index) ([]*search.Resource, error) {
|
||||
rootResource, err := searchResourceByID(id, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootResource.Deleted = state
|
||||
|
||||
resources := []*search.Resource{rootResource}
|
||||
|
||||
if rootResource.Type == uint64(storageProvider.ResourceType_RESOURCE_TYPE_CONTAINER) {
|
||||
descendantResources, err := searchResourcesByPath(rootResource.RootID, rootResource.Path, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, descendantResource := range descendantResources {
|
||||
descendantResource.Deleted = state
|
||||
resources = append(resources, descendantResource)
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/config/parser"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "check health status",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return parser.ParseConfig(cfg)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
|
||||
resp, err := http.Get(
|
||||
fmt.Sprintf(
|
||||
"http://%s/healthz",
|
||||
cfg.Debug.Addr,
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to request health check")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Fatal().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health seems to be in bad state")
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health got a good state")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/config/parser"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Index is the entrypoint for the server command.
|
||||
func Index(cfg *config.Config) *cobra.Command {
|
||||
indexCmd := &cobra.Command{
|
||||
Use: "index",
|
||||
Short: "index the files for one one more users",
|
||||
Aliases: []string{"i"},
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnFatal(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
allSpacesFlag, _ := cmd.Flags().GetBool("all-spaces")
|
||||
spaceFlag, _ := cmd.Flags().GetString("space")
|
||||
forceRescanFlag, _ := cmd.Flags().GetBool("force-rescan")
|
||||
endpointFlag, _ := cmd.Flags().GetString("endpoint")
|
||||
insecureFlag, _ := cmd.Flags().GetBool("insecure")
|
||||
if spaceFlag == "" && !allSpacesFlag {
|
||||
return errors.New("either --space or --all-spaces is required")
|
||||
}
|
||||
|
||||
var dialOpts []grpc.DialOption
|
||||
if cfg.GRPCClientTLS.Mode == "insecure" || insecureFlag {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})))
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(endpointFlag, dialOpts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dial %s: %w", endpointFlag, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c := searchsvc.NewSearchProviderClient(conn)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
_, err = c.IndexSpace(ctx, &searchsvc.IndexSpaceRequest{
|
||||
SpaceId: spaceFlag,
|
||||
ForceReindex: forceRescanFlag,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("failed to index space: " + err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
indexCmd.Flags().StringP(
|
||||
"space",
|
||||
"s",
|
||||
"",
|
||||
"the id of the space to travers and index the files of. This or --all-spaces is required.")
|
||||
|
||||
indexCmd.Flags().Bool(
|
||||
"all-spaces",
|
||||
false,
|
||||
"index all spaces instead. This or --space is required.",
|
||||
)
|
||||
indexCmd.Flags().Bool(
|
||||
"force-rescan",
|
||||
false,
|
||||
"force a rescan of all files, even if they are already indexed. This will make the indexing process much slower, but ensures that the index is up-to-date using the current search service configuration.",
|
||||
)
|
||||
indexCmd.Flags().String(
|
||||
"endpoint",
|
||||
"127.0.0.1:9220",
|
||||
"the address of the search service gRPC endpoint.",
|
||||
)
|
||||
indexCmd.Flags().Bool(
|
||||
"insecure",
|
||||
false,
|
||||
"disable TLS for the gRPC connection.",
|
||||
)
|
||||
|
||||
return indexCmd
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/clihelper"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) []*cobra.Command {
|
||||
return []*cobra.Command{
|
||||
// start this service
|
||||
Server(cfg),
|
||||
|
||||
// interaction with this service
|
||||
Index(cfg),
|
||||
|
||||
// infos about this service
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the qsfera-search command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Serve search API for КуСфера",
|
||||
})
|
||||
app.AddCommand(GetCommands(cfg)...)
|
||||
app.SetArgs(os.Args[1:])
|
||||
|
||||
return app.ExecuteContext(cfg.Context)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/configlog"
|
||||
"github.com/qsfera/server/pkg/generators"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/runner"
|
||||
ogrpc "github.com/qsfera/server/pkg/service/grpc"
|
||||
"github.com/qsfera/server/pkg/tracing"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/search/pkg/bleve"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/config/parser"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
"github.com/qsfera/server/services/search/pkg/metrics"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch"
|
||||
bleveQuery "github.com/qsfera/server/services/search/pkg/query/bleve"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
"github.com/qsfera/server/services/search/pkg/server/debug"
|
||||
"github.com/qsfera/server/services/search/pkg/server/grpc"
|
||||
svcEvent "github.com/qsfera/server/services/search/pkg/service/event"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "server",
|
||||
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return configlog.ReturnFatal(parser.ParseConfig(cfg))
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
|
||||
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.GrpcClient, err = ogrpc.NewClient(
|
||||
append(ogrpc.GetClientOptions(cfg.GRPCClientTLS), ogrpc.WithTraceProvider(traceProvider))...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
if cfg.Context == nil {
|
||||
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
|
||||
defer cancel()
|
||||
}
|
||||
ctx := cfg.Context
|
||||
|
||||
mtrcs := metrics.New()
|
||||
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
// initialize search engine
|
||||
var eng search.Engine
|
||||
switch cfg.Engine.Type {
|
||||
case "bleve":
|
||||
idx, err := bleve.NewIndex(cfg.Engine.Bleve.Datapath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err = idx.Close(); err != nil {
|
||||
logger.Error().Err(err).Msg("could not close bleve index")
|
||||
}
|
||||
}()
|
||||
|
||||
eng = bleve.NewBackend(idx, bleveQuery.DefaultCreator, logger)
|
||||
case "open-search":
|
||||
clientConfig := opensearchgo.Config{
|
||||
Addresses: cfg.Engine.OpenSearch.Client.Addresses,
|
||||
Username: cfg.Engine.OpenSearch.Client.Username,
|
||||
Password: cfg.Engine.OpenSearch.Client.Password,
|
||||
Header: cfg.Engine.OpenSearch.Client.Header,
|
||||
RetryOnStatus: cfg.Engine.OpenSearch.Client.RetryOnStatus,
|
||||
DisableRetry: cfg.Engine.OpenSearch.Client.DisableRetry,
|
||||
EnableRetryOnTimeout: cfg.Engine.OpenSearch.Client.EnableRetryOnTimeout,
|
||||
MaxRetries: cfg.Engine.OpenSearch.Client.MaxRetries,
|
||||
CompressRequestBody: cfg.Engine.OpenSearch.Client.CompressRequestBody,
|
||||
DiscoverNodesOnStart: cfg.Engine.OpenSearch.Client.DiscoverNodesOnStart,
|
||||
DiscoverNodesInterval: cfg.Engine.OpenSearch.Client.DiscoverNodesInterval,
|
||||
EnableMetrics: cfg.Engine.OpenSearch.Client.EnableMetrics,
|
||||
EnableDebugLogger: cfg.Engine.OpenSearch.Client.EnableDebugLogger,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: cfg.Engine.OpenSearch.Client.Insecure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Engine.OpenSearch.Client.CACert != "" {
|
||||
certBytes, err := os.ReadFile(cfg.Engine.OpenSearch.Client.CACert)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CA cert: %w", err)
|
||||
}
|
||||
clientConfig.CACert = certBytes
|
||||
}
|
||||
|
||||
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{Client: clientConfig})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OpenSearch client: %w", err)
|
||||
}
|
||||
|
||||
openSearchBackend, err := opensearch.NewBackend(cfg.Engine.OpenSearch.ResourceIndex.Name, client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OpenSearch backend: %w", err)
|
||||
}
|
||||
|
||||
eng = openSearchBackend
|
||||
default:
|
||||
return fmt.Errorf("unknown search engine: %s", cfg.Engine.Type)
|
||||
}
|
||||
|
||||
// initialize gateway selector
|
||||
selector, err := pool.GatewaySelector(cfg.Reva.Address, pool.WithRegistry(registry.GetRegistry()), pool.WithTracerProvider(traceProvider))
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("could not get reva gateway selector")
|
||||
return err
|
||||
}
|
||||
|
||||
// initialize search content extractor
|
||||
var extractor content.Extractor
|
||||
switch cfg.Extractor.Type {
|
||||
case "basic":
|
||||
if extractor, err = content.NewBasicExtractor(logger); err != nil {
|
||||
return err
|
||||
}
|
||||
case "tika":
|
||||
if extractor, err = content.NewTikaExtractor(selector, logger, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown search extractor: %s", cfg.Extractor.Type)
|
||||
}
|
||||
|
||||
ss := search.NewService(selector, eng, extractor, mtrcs, logger, cfg)
|
||||
|
||||
// setup the servers
|
||||
gr := runner.NewGroup()
|
||||
|
||||
if !cfg.GRPC.Disabled {
|
||||
grpcServer, err := grpc.Server(
|
||||
grpc.Config(cfg),
|
||||
grpc.Logger(logger),
|
||||
grpc.Name(cfg.Service.Name),
|
||||
grpc.Context(ctx),
|
||||
grpc.Metrics(mtrcs),
|
||||
grpc.JWTSecret(cfg.TokenManager.JWTSecret),
|
||||
grpc.TraceProvider(traceProvider),
|
||||
grpc.GatewaySelector(selector),
|
||||
grpc.Searcher(ss),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "grpc").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGoMicroGrpcServerRunner(cfg.Service.Name+".grpc", grpcServer))
|
||||
} else {
|
||||
logger.Info().Msg("gRPC server disabled, not starting gRPC service")
|
||||
}
|
||||
|
||||
if !cfg.Events.Disabled {
|
||||
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
|
||||
bus, err := raw.FromConfig(context.Background(), connName, raw.Config{
|
||||
Endpoint: cfg.Events.Endpoint,
|
||||
Cluster: cfg.Events.Cluster,
|
||||
EnableTLS: cfg.Events.EnableTLS,
|
||||
TLSInsecure: cfg.Events.TLSInsecure,
|
||||
TLSRootCACertificate: cfg.Events.TLSRootCACertificate,
|
||||
AuthUsername: cfg.Events.AuthUsername,
|
||||
AuthPassword: cfg.Events.AuthPassword,
|
||||
MaxAckPending: cfg.Events.MaxAckPending,
|
||||
AckWait: cfg.Events.AckWait,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to create event bus client")
|
||||
return err
|
||||
}
|
||||
|
||||
eventSvc, err := svcEvent.New(ctx, bus, logger, traceProvider, mtrcs, ss, cfg.Events.DebounceDuration, cfg.Events.NumConsumers, cfg.Events.AsyncUploads)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "event").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.New(cfg.Service.Name+".svc", func() error {
|
||||
return eventSvc.Run()
|
||||
}, func() {
|
||||
eventSvc.Close()
|
||||
}))
|
||||
} else {
|
||||
logger.Info().Msg("event listening disabled, not starting event service")
|
||||
}
|
||||
|
||||
// always start a debug server
|
||||
{
|
||||
debugServer, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(ctx),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
|
||||
}
|
||||
|
||||
grResults := gr.Run(ctx)
|
||||
|
||||
// return the first non-nil error found in the results
|
||||
for _, grResult := range grResults {
|
||||
if grResult.RunnerError != nil {
|
||||
return grResult.RunnerError
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "print the version of this binary and the running service instances",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Version: " + version.GetString())
|
||||
fmt.Printf("Compiled: %s\n", version.Compiled())
|
||||
fmt.Println("")
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
services, err := reg.GetService(cfg.GRPC.Namespace + "." + cfg.Service.Name)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No running " + cfg.Service.Name + " service found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
|
||||
table.Header([]string{"Version", "Address", "Id"})
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
table.Append([]string{s.Version, n.Address, n.Id})
|
||||
}
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"go-micro.dev/v4/client"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
|
||||
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;SEARCH_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
GRPC GRPCConfig `yaml:"grpc"`
|
||||
GrpcClient client.Client `yaml:"-"`
|
||||
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
Reva *shared.Reva `yaml:"reva"`
|
||||
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
|
||||
Events Events `yaml:"events"`
|
||||
Engine Engine `yaml:"engine"`
|
||||
Extractor Extractor `yaml:"extractor"`
|
||||
ContentExtractionSizeLimit uint64 `yaml:"content_extraction_size_limit" env:"SEARCH_CONTENT_EXTRACTION_SIZE_LIMIT" desc:"Maximum file size in bytes that is allowed for content extraction." introductionVersion:"1.0.0"`
|
||||
BatchSize int `yaml:"batch_size" env:"SEARCH_BATCH_SIZE" desc:"The number of documents to process in a single batch. Defaults to 500." introductionVersion:"1.0.0"`
|
||||
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
// ServiceAccount is the configuration for the used service account
|
||||
type ServiceAccount struct {
|
||||
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;SEARCH_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
|
||||
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;SEARCH_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
// Extractor defines which extractor to use
|
||||
type Extractor struct {
|
||||
Type string `yaml:"type" env:"SEARCH_EXTRACTOR_TYPE" desc:"Defines the content extraction engine. Defaults to 'basic'. Supported values are: 'basic' and 'tika'." introductionVersion:"1.0.0"`
|
||||
CS3AllowInsecure bool `yaml:"cs3_allow_insecure" env:"OC_INSECURE;SEARCH_EXTRACTOR_CS3SOURCE_INSECURE" desc:"Ignore untrusted SSL certificates when connecting to the CS3 source." introductionVersion:"1.0.0"`
|
||||
Tika ExtractorTika `yaml:"tika"`
|
||||
}
|
||||
|
||||
// ExtractorTika configures the Tika extractor
|
||||
type ExtractorTika struct {
|
||||
TikaURL string `yaml:"tika_url" env:"SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"`
|
||||
CleanStopWords bool `yaml:"clean_stop_words" env:"SEARCH_EXTRACTOR_TIKA_CLEAN_STOP_WORDS" desc:"Defines if stop words should be cleaned or not. See the documentation for more details." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"SEARCH_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
|
||||
Token string `yaml:"token" env:"SEARCH_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
|
||||
Pprof bool `yaml:"pprof" env:"SEARCH_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
|
||||
Zpages bool `yaml:"zpages" env:"SEARCH_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/defaults"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/pkg/structs"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
)
|
||||
|
||||
// FullDefaultConfig returns a fully initialized default configuration
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
EnsureDefaults(cfg)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// DefaultConfig returns a basic default configuration
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9224",
|
||||
Token: "",
|
||||
},
|
||||
GRPC: config.GRPCConfig{
|
||||
Addr: "127.0.0.1:9220",
|
||||
Namespace: "qsfera.api",
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "search",
|
||||
},
|
||||
Reva: shared.DefaultRevaConfig(),
|
||||
Engine: config.Engine{
|
||||
Type: "bleve",
|
||||
Bleve: config.EngineBleve{
|
||||
Datapath: filepath.Join(defaults.BaseDataPath(), "search"),
|
||||
},
|
||||
OpenSearch: config.EngineOpenSearch{
|
||||
ResourceIndex: config.EngineOpenSearchResourceIndex{
|
||||
Name: "qsfera-resource",
|
||||
},
|
||||
},
|
||||
},
|
||||
Extractor: config.Extractor{
|
||||
Type: "basic",
|
||||
CS3AllowInsecure: false,
|
||||
Tika: config.ExtractorTika{
|
||||
TikaURL: "http://127.0.0.1:9998",
|
||||
CleanStopWords: false,
|
||||
},
|
||||
},
|
||||
Events: config.Events{
|
||||
Endpoint: "127.0.0.1:9233",
|
||||
Cluster: "qsfera-cluster",
|
||||
DebounceDuration: 1000,
|
||||
AsyncUploads: true,
|
||||
NumConsumers: 1,
|
||||
EnableTLS: false,
|
||||
MaxAckPending: 1000,
|
||||
AckWait: 1 * time.Minute,
|
||||
},
|
||||
ContentExtractionSizeLimit: 20 * 1024 * 1024, // Limit content extraction to <20MB files by default
|
||||
BatchSize: 50,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDefaults adds default values to the configuration if they are not set yet
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
if cfg.LogLevel == "" {
|
||||
cfg.LogLevel = "error"
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
|
||||
if cfg.Reva == nil && cfg.Commons != nil {
|
||||
cfg.Reva = structs.CopyOrZeroValue(cfg.Commons.Reva)
|
||||
}
|
||||
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
|
||||
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
|
||||
}
|
||||
if cfg.GRPC.TLS == nil && cfg.Commons != nil {
|
||||
cfg.GRPC.TLS = structs.CopyOrZeroValue(cfg.Commons.GRPCServiceTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize sanitizes the configuration
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// no http endpoint to be sanitized
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Engine defines which search engine to use
|
||||
type Engine struct {
|
||||
Type string `yaml:"type" env:"SEARCH_ENGINE_TYPE" desc:"Defines which search engine to use. Defaults to 'bleve'. Supported values are: 'bleve'." introductionVersion:"1.0.0"`
|
||||
Bleve EngineBleve `yaml:"bleve"`
|
||||
OpenSearch EngineOpenSearch `yaml:"open_search"`
|
||||
}
|
||||
|
||||
// EngineBleve configures the bleve engine
|
||||
type EngineBleve struct {
|
||||
Datapath string `yaml:"data_path" env:"SEARCH_ENGINE_BLEVE_DATA_PATH" desc:"The directory where the filesystem will store search data. If not defined, the root directory derives from $OC_BASE_DATA_PATH/search." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// EngineOpenSearch configures the OpenSearch engine
|
||||
type EngineOpenSearch struct {
|
||||
Client EngineOpenSearchClient `yaml:"client"`
|
||||
ResourceIndex EngineOpenSearchResourceIndex `yaml:"resource_index"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// EngineOpenSearchClient configures the OpenSearch client
|
||||
type EngineOpenSearchClient struct {
|
||||
Addresses []string `yaml:"addresses" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ADDRESSES" desc:"The addresses of the OpenSearch nodes.." introductionVersion:"4.0.0"`
|
||||
Username string `yaml:"username" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_USERNAME" desc:"Username for HTTP Basic Authentication." introductionVersion:"4.0.0"`
|
||||
Password string `yaml:"password" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_PASSWORD" desc:"Password for HTTP Basic Authentication." introductionVersion:"4.0.0"`
|
||||
Header http.Header `yaml:"header" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_HEADER" desc:"HTTP headers to include in requests." introductionVersion:"4.0.0"`
|
||||
CACert string `yaml:"ca_cert" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_CA_CERT" desc:"Path/File name for the root CA certificate (in PEM format) used to validate TLS server certificates of the opensearch server." introductionVersion:"4.0.0"`
|
||||
RetryOnStatus []int `yaml:"retry_on_status" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_RETRY_ON_STATUS" desc:"HTTP status codes that trigger a retry." introductionVersion:"4.0.0"`
|
||||
DisableRetry bool `yaml:"disable_retry" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_DISABLE_RETRY" desc:"Disable retries on errors." introductionVersion:"4.0.0"`
|
||||
EnableRetryOnTimeout bool `yaml:"enable_retry_on_timeout" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ENABLE_RETRY_ON_TIMEOUT" desc:"Enable retries on timeout." introductionVersion:"4.0.0"`
|
||||
MaxRetries int `yaml:"max_retries" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_MAX_RETRIES" desc:"Maximum number of retries for requests." introductionVersion:"4.0.0"`
|
||||
CompressRequestBody bool `yaml:"compress_request_body" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_COMPRESS_REQUEST_BODY" desc:"Compress request bodies." introductionVersion:"4.0.0"`
|
||||
DiscoverNodesOnStart bool `yaml:"discover_nodes_on_start" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_DISCOVER_NODES_ON_START" desc:"Discover nodes on service start." introductionVersion:"4.0.0"`
|
||||
DiscoverNodesInterval time.Duration `yaml:"discover_nodes_interval" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_DISCOVER_NODES_INTERVAL" desc:"Interval for discovering nodes." introductionVersion:"4.0.0"`
|
||||
EnableMetrics bool `yaml:"enable_metrics" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ENABLE_METRICS" desc:"Enable metrics collection." introductionVersion:"4.0.0"`
|
||||
EnableDebugLogger bool `yaml:"enable_debug_logger" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_ENABLE_DEBUG_LOGGER" desc:"Enable debug logging." introductionVersion:"4.0.0"`
|
||||
Insecure bool `yaml:"insecure" env:"SEARCH_ENGINE_OPEN_SEARCH_CLIENT_INSECURE" desc:"Skip TLS certificate verification." introductionVersion:"4.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
import "github.com/qsfera/server/pkg/shared"
|
||||
|
||||
// GRPCConfig defines the available grpc configuration.
|
||||
type GRPCConfig struct {
|
||||
Disabled bool `yaml:"disabled" env:"SEARCH_GRPC_DISABLED" desc:"Disables the GRPC service. Set this to true if the service should only handle events." introductionVersion:"4.0.0"`
|
||||
Addr string `yaml:"addr" env:"SEARCH_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"`
|
||||
Namespace string `yaml:"-"`
|
||||
TLS *shared.GRPCServiceTLS `yaml:"tls"`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
occfg "github.com/qsfera/server/pkg/config"
|
||||
"github.com/qsfera/server/pkg/shared"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/config/defaults"
|
||||
|
||||
"github.com/qsfera/server/pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
if cfg.ServiceAccount.ServiceAccountID == "" {
|
||||
return shared.MissingServiceAccountID(cfg.Service.Name)
|
||||
}
|
||||
if cfg.ServiceAccount.ServiceAccountSecret == "" {
|
||||
return shared.MissingServiceAccountSecret(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
// Reva defines all available REVA configuration.
|
||||
type Reva struct {
|
||||
Address string `yaml:"address" env:"OC_REVA_GATEWAY" desc:"The CS3 gateway endpoint." introductionVersion:"1.0.0"`
|
||||
}
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;SEARCH_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Disabled bool `yaml:"disabled" env:"SEARCH_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle GRPC requests." introductionVersion:"4.0.0"`
|
||||
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;SEARCH_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"1.0.0"`
|
||||
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;SEARCH_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Mandatory when using NATS as event system." introductionVersion:"1.0.0"`
|
||||
AsyncUploads bool `yaml:"async_uploads" env:"OC_ASYNC_UPLOADS;SEARCH_EVENTS_ASYNC_UPLOADS" desc:"Enable asynchronous file uploads." introductionVersion:"1.0.0"`
|
||||
NumConsumers int `yaml:"num_consumers" env:"SEARCH_EVENTS_NUM_CONSUMERS" desc:"The amount of concurrent event consumers to start. Event consumers are used for searching files. Multiple consumers increase parallelisation, but will also increase CPU and memory demands." introductionVersion:"1.0.0"`
|
||||
DebounceDuration int `yaml:"debounce_duration" env:"SEARCH_EVENTS_REINDEX_DEBOUNCE_DURATION" desc:"The duration in milliseconds the reindex debouncer waits before triggering a reindex of a space that was modified." introductionVersion:"1.0.0"`
|
||||
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;SEARCH_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;SEARCH_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided SEARCH_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;SEARCH_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;SEARCH_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;SEARCH_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
|
||||
|
||||
MaxAckPending int `yaml:"max_ack_pending" env:"SEARCH_EVENTS_MAX_ACK_PENDING" desc:"The maximum number of unacknowledged messages. This is used to limit the number of messages that can be in flight at the same time." introductionVersion:"4.0.0"`
|
||||
AckWait time.Duration `yaml:"ack_wait" env:"SEARCH_EVENTS_ACK_WAIT" desc:"The time to wait for an ack before the message is redelivered. This is used to ensure that messages are not lost if the consumer crashes." introductionVersion:"4.0.0"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/tags"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
)
|
||||
|
||||
// Basic is the simplest Extractor implementation.
|
||||
type Basic struct {
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// NewBasicExtractor creates a new Basic instance.
|
||||
func NewBasicExtractor(logger log.Logger) (*Basic, error) {
|
||||
return &Basic{logger: logger}, nil
|
||||
}
|
||||
|
||||
// Extract literally just rearranges the inputs and processes them into a Document.
|
||||
func (b Basic) Extract(_ context.Context, ri *storageProvider.ResourceInfo) (Document, error) {
|
||||
doc := Document{
|
||||
Name: ri.Name,
|
||||
Size: ri.Size,
|
||||
MimeType: ri.MimeType,
|
||||
}
|
||||
|
||||
if m := ri.ArbitraryMetadata.GetMetadata(); m != nil {
|
||||
if t, ok := m["tags"]; ok {
|
||||
doc.Tags = tags.New(t).AsSlice()
|
||||
}
|
||||
}
|
||||
|
||||
if m := ri.Opaque.GetMap(); m != nil && m["favorites"] != nil {
|
||||
favEntry := m["favorites"]
|
||||
|
||||
switch favEntry.Decoder {
|
||||
case "json":
|
||||
favorites := []string{}
|
||||
err := json.Unmarshal(favEntry.Value, &favorites)
|
||||
if err != nil {
|
||||
b.logger.Error().Err(err).Msg("failed to unmarshal favorites")
|
||||
break
|
||||
}
|
||||
|
||||
doc.Favorites = favorites
|
||||
default:
|
||||
b.logger.Error().Msgf("unsupported decoder for favorites: %s", favEntry.Decoder)
|
||||
}
|
||||
}
|
||||
|
||||
if ri.Mtime != nil {
|
||||
doc.Mtime = utils.TSToTime(ri.Mtime).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
cs3Types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
)
|
||||
|
||||
var _ = Describe("Basic", func() {
|
||||
var (
|
||||
basic content.Extractor
|
||||
logger = log.NewLogger()
|
||||
ctx = context.TODO()
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
basic, _ = content.NewBasicExtractor(logger)
|
||||
})
|
||||
|
||||
Describe("extract", func() {
|
||||
It("basic fields", func() {
|
||||
ri := &storageProvider.ResourceInfo{
|
||||
Name: "bar.pdf",
|
||||
Path: "./foo/bar.pdf",
|
||||
Size: 1024,
|
||||
MimeType: "application/pdf",
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Name).To(Equal(ri.Name))
|
||||
Expect(doc.Size).To(Equal(ri.Size))
|
||||
Expect(doc.MimeType).To(Equal(ri.MimeType))
|
||||
})
|
||||
|
||||
It("adds tags", func() {
|
||||
for _, data := range []struct {
|
||||
tags string
|
||||
expect []string
|
||||
}{
|
||||
{tags: "", expect: []string{}},
|
||||
{tags: ",,,", expect: []string{}},
|
||||
{tags: ",foo,,", expect: []string{"foo"}},
|
||||
{tags: ",foo,,bar,", expect: []string{"foo", "bar"}},
|
||||
} {
|
||||
ri := &storageProvider.ResourceInfo{
|
||||
ArbitraryMetadata: &storageProvider.ArbitraryMetadata{
|
||||
Metadata: map[string]string{
|
||||
"tags": data.tags,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Tags).To(Equal(data.expect))
|
||||
}
|
||||
})
|
||||
|
||||
It("RFC3339 mtime", func() {
|
||||
for _, data := range []struct {
|
||||
second uint64
|
||||
expect string
|
||||
}{
|
||||
{second: 4000, expect: "1970-01-01T01:06:40Z"},
|
||||
{second: 3000, expect: "1970-01-01T00:50:00Z"},
|
||||
{expect: ""},
|
||||
} {
|
||||
ri := &storageProvider.ResourceInfo{}
|
||||
|
||||
if data.second != 0 {
|
||||
ri.Mtime = &cs3Types.Timestamp{Seconds: data.second}
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Mtime).To(Equal(data.expect))
|
||||
}
|
||||
})
|
||||
|
||||
It("extracts favorites", func() {
|
||||
favorites := []string{"foo", "bar"}
|
||||
favBytes, _ := json.Marshal(favorites)
|
||||
|
||||
ri := &storageProvider.ResourceInfo{
|
||||
Opaque: &cs3Types.Opaque{
|
||||
Map: map[string]*cs3Types.OpaqueEntry{
|
||||
"favorites": {
|
||||
Decoder: "json",
|
||||
Value: favBytes,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
doc, err := basic.Extract(ctx, ri)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(doc).ToNot(BeNil())
|
||||
Expect(doc.Favorites).To(Equal(favorites))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/bbalet/stopwords"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
func init() {
|
||||
stopwords.OverwriteWordSegmenter(`[^ ]+`)
|
||||
}
|
||||
|
||||
// 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
|
||||
MimeType string
|
||||
Tags []string
|
||||
Favorites []string
|
||||
Audio *libregraph.Audio `json:"audio,omitempty"`
|
||||
Image *libregraph.Image `json:"image,omitempty"`
|
||||
Location *libregraph.GeoCoordinates `json:"location,omitempty"`
|
||||
Photo *libregraph.Photo `json:"photo,omitempty"`
|
||||
}
|
||||
|
||||
func CleanString(content, langCode string) string {
|
||||
return strings.TrimSpace(stopwords.CleanString(content, langCode, true))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestContent(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Content Suite")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
)
|
||||
|
||||
func TestCleanContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
given string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
given: "find can keeper should keeper will",
|
||||
expect: "keeper keeper",
|
||||
},
|
||||
{
|
||||
given: "user1 shares the file to Mary",
|
||||
expect: "user1 shares file mary",
|
||||
},
|
||||
{
|
||||
given: "content contains https://localhost/remote.php/dav/files/admin/Photos/San%20Francisco.jpg and stop word",
|
||||
expect: "content contains https://localhost/remote.php/dav/files/admin/photos/san%20francisco.jpg stop word",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.given, func(t *testing.T) {
|
||||
Equal(t, tc.expect, content.CleanString(tc.given, "en"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
type cs3 struct {
|
||||
httpClient http.Client
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func newCS3Retriever(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger, insecure bool) cs3 {
|
||||
return cs3{
|
||||
httpClient: http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, //nolint:gosec
|
||||
},
|
||||
},
|
||||
gatewaySelector: gatewaySelector,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve downloads the file from a cs3 service
|
||||
// The caller MUST make sure to close the returned ReadCloser
|
||||
func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) {
|
||||
at, ok := contextGet(ctx, revactx.TokenHeader)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("context without %s", revactx.TokenHeader)
|
||||
}
|
||||
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("could not get reva gatewayClient")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := gatewayClient.InitiateFileDownload(ctx, &provider.InitiateFileDownloadRequest{Ref: &provider.Reference{ResourceId: rID, Path: "."}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Status.Code != rpc.Code_CODE_OK {
|
||||
return nil, fmt.Errorf("could not load resoure: %s", res.Status.Message)
|
||||
}
|
||||
|
||||
var ep, tt string
|
||||
for _, p := range res.Protocols {
|
||||
if p.Protocol == "spaces" {
|
||||
ep, tt = p.DownloadEndpoint, p.Token
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ep == "" || tt == "") && len(res.Protocols) > 0 {
|
||||
ep, tt = res.Protocols[0].DownloadEndpoint, res.Protocols[0].Token
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, ep, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set(revactx.TokenHeader, at)
|
||||
req.Header.Set("X-Reva-Transfer", tt)
|
||||
|
||||
cres, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cres.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("could not download resource. Request returned with statuscode %d ", cres.StatusCode)
|
||||
}
|
||||
|
||||
return cres.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
)
|
||||
|
||||
// Extractor is responsible to extract content and meta information from documents.
|
||||
type Extractor interface {
|
||||
Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, error)
|
||||
}
|
||||
|
||||
func getFirstValue(m map[string][]string, key string) (string, error) {
|
||||
if m == nil {
|
||||
return "", errors.New("undefined map")
|
||||
}
|
||||
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unknown key: %v", key)
|
||||
}
|
||||
|
||||
if len(m) == 0 {
|
||||
return "", fmt.Errorf("no values for: %v", key)
|
||||
}
|
||||
|
||||
return v[0], nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewExtractor creates a new instance of Extractor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewExtractor(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Extractor {
|
||||
mock := &Extractor{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Extractor is an autogenerated mock type for the Extractor type
|
||||
type Extractor struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Extractor_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Extractor) EXPECT() *Extractor_Expecter {
|
||||
return &Extractor_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Extract provides a mock function for the type Extractor
|
||||
func (_mock *Extractor) Extract(ctx context.Context, ri *providerv1beta1.ResourceInfo) (content.Document, error) {
|
||||
ret := _mock.Called(ctx, ri)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Extract")
|
||||
}
|
||||
|
||||
var r0 content.Document
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceInfo) (content.Document, error)); ok {
|
||||
return returnFunc(ctx, ri)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceInfo) content.Document); ok {
|
||||
r0 = returnFunc(ctx, ri)
|
||||
} else {
|
||||
r0 = ret.Get(0).(content.Document)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceInfo) error); ok {
|
||||
r1 = returnFunc(ctx, ri)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Extractor_Extract_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extract'
|
||||
type Extractor_Extract_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Extract is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - ri *providerv1beta1.ResourceInfo
|
||||
func (_e *Extractor_Expecter) Extract(ctx interface{}, ri interface{}) *Extractor_Extract_Call {
|
||||
return &Extractor_Extract_Call{Call: _e.mock.On("Extract", ctx, ri)}
|
||||
}
|
||||
|
||||
func (_c *Extractor_Extract_Call) Run(run func(ctx context.Context, ri *providerv1beta1.ResourceInfo)) *Extractor_Extract_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *providerv1beta1.ResourceInfo
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*providerv1beta1.ResourceInfo)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Extractor_Extract_Call) Return(document content.Document, err error) *Extractor_Extract_Call {
|
||||
_c.Call.Return(document, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Extractor_Extract_Call) RunAndReturn(run func(ctx context.Context, ri *providerv1beta1.ResourceInfo) (content.Document, error)) *Extractor_Extract_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewRetriever creates a new instance of Retriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewRetriever(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Retriever {
|
||||
mock := &Retriever{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Retriever is an autogenerated mock type for the Retriever type
|
||||
type Retriever struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Retriever_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Retriever) EXPECT() *Retriever_Expecter {
|
||||
return &Retriever_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Retrieve provides a mock function for the type Retriever
|
||||
func (_mock *Retriever) Retrieve(ctx context.Context, rID *providerv1beta1.ResourceId) (io.ReadCloser, error) {
|
||||
ret := _mock.Called(ctx, rID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Retrieve")
|
||||
}
|
||||
|
||||
var r0 io.ReadCloser
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId) (io.ReadCloser, error)); ok {
|
||||
return returnFunc(ctx, rID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId) io.ReadCloser); ok {
|
||||
r0 = returnFunc(ctx, rID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(io.ReadCloser)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId) error); ok {
|
||||
r1 = returnFunc(ctx, rID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Retriever_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve'
|
||||
type Retriever_Retrieve_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Retrieve is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - rID *providerv1beta1.ResourceId
|
||||
func (_e *Retriever_Expecter) Retrieve(ctx interface{}, rID interface{}) *Retriever_Retrieve_Call {
|
||||
return &Retriever_Retrieve_Call{Call: _e.mock.On("Retrieve", ctx, rID)}
|
||||
}
|
||||
|
||||
func (_c *Retriever_Retrieve_Call) Run(run func(ctx context.Context, rID *providerv1beta1.ResourceId)) *Retriever_Retrieve_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *providerv1beta1.ResourceId
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*providerv1beta1.ResourceId)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Retriever_Retrieve_Call) Return(readCloser io.ReadCloser, err error) *Retriever_Retrieve_Call {
|
||||
_c.Call.Return(readCloser, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Retriever_Retrieve_Call) RunAndReturn(run func(ctx context.Context, rID *providerv1beta1.ResourceId) (io.ReadCloser, error)) *Retriever_Retrieve_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Retriever is the interface that wraps the basic Retrieve method. 🐕
|
||||
// It requests and then returns a resource from the underlying storage.
|
||||
type Retriever interface {
|
||||
Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func contextGet(ctx context.Context, k string) (string, bool) {
|
||||
md, ok := metadata.FromOutgoingContext(ctx)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token, ok := md[k]
|
||||
if len(token) == 0 || !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return token[0], ok
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/google/go-tika/tika"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
)
|
||||
|
||||
// Tika is used to extract content from a resource,
|
||||
// it uses apache tika to retrieve all the data.
|
||||
type Tika struct {
|
||||
*Basic
|
||||
Retriever
|
||||
tika *tika.Client
|
||||
ContentExtractionSizeLimit uint64
|
||||
CleanStopWords bool
|
||||
}
|
||||
|
||||
// NewTikaExtractor creates a new Tika instance.
|
||||
func NewTikaExtractor(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger, cfg *config.Config) (*Tika, error) {
|
||||
basic, err := NewBasicExtractor(logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tk := tika.NewClient(nil, cfg.Extractor.Tika.TikaURL)
|
||||
tkv, err := tk.Version(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info().Msgf("Tika version: %s", tkv)
|
||||
|
||||
return &Tika{
|
||||
Basic: basic,
|
||||
Retriever: newCS3Retriever(gatewaySelector, logger, cfg.Extractor.CS3AllowInsecure),
|
||||
tika: tika.NewClient(nil, cfg.Extractor.Tika.TikaURL),
|
||||
ContentExtractionSizeLimit: cfg.ContentExtractionSizeLimit,
|
||||
CleanStopWords: cfg.Extractor.Tika.CleanStopWords,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extract loads a resource from its underlying storage, passes it to tika and processes the result into a Document.
|
||||
func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, error) {
|
||||
doc, err := t.Basic.Extract(ctx, ri)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
|
||||
if ri.Size == 0 {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
if ri.Size > t.ContentExtractionSizeLimit {
|
||||
t.logger.Info().Interface("ResourceID", ri.Id).Str("Name", ri.Name).Msg("file exceeds content extraction size limit. skipping.")
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
if ri.Type != provider.ResourceType_RESOURCE_TYPE_FILE {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
data, err := t.Retrieve(ctx, ri.Id)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
defer data.Close()
|
||||
|
||||
metas, err := t.tika.MetaRecursive(ctx, data)
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
|
||||
for _, meta := range metas {
|
||||
if title, err := getFirstValue(meta, "title"); err == nil {
|
||||
doc.Title = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Title, title))
|
||||
}
|
||||
|
||||
if content, err := getFirstValue(meta, "X-TIKA:content"); err == nil {
|
||||
doc.Content = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Content, content))
|
||||
}
|
||||
|
||||
doc.Location = t.getLocation(meta)
|
||||
doc.Image = t.getImage(meta)
|
||||
doc.Photo = t.getPhoto(meta)
|
||||
|
||||
if contentType, err := getFirstValue(meta, "Content-Type"); err == nil && strings.HasPrefix(contentType, "audio/") {
|
||||
doc.Audio = t.getAudio(meta)
|
||||
}
|
||||
}
|
||||
|
||||
if langCode, _ := t.tika.LanguageString(ctx, doc.Content); langCode != "" && t.CleanStopWords {
|
||||
doc.Content = CleanString(doc.Content, langCode)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (t Tika) getImage(meta map[string][]string) *libregraph.Image {
|
||||
var image *libregraph.Image
|
||||
initImage := func() {
|
||||
if image == nil {
|
||||
image = libregraph.NewImage()
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:ImageWidth"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initImage()
|
||||
image.SetWidth(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:ImageLength"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initImage()
|
||||
image.SetHeight(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
func (t Tika) getLocation(meta map[string][]string) *libregraph.GeoCoordinates {
|
||||
var location *libregraph.GeoCoordinates
|
||||
initLocation := func() {
|
||||
if location == nil {
|
||||
location = libregraph.NewGeoCoordinates()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: location.Altitute: transform the following data to … feet above sea level.
|
||||
// "GPS:GPS Altitude": []string{"227.4 metres"},
|
||||
// "GPS:GPS Altitude Ref": []string{"Sea level"},
|
||||
|
||||
if v, err := getFirstValue(meta, "geo:lat"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initLocation()
|
||||
location.SetLatitude(i)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "geo:long"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initLocation()
|
||||
location.SetLongitude(i)
|
||||
}
|
||||
}
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
func (t Tika) getPhoto(meta map[string][]string) *libregraph.Photo {
|
||||
var photo *libregraph.Photo
|
||||
initPhoto := func() {
|
||||
if photo == nil {
|
||||
photo = libregraph.NewPhoto()
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:Make"); err == nil {
|
||||
initPhoto()
|
||||
photo.SetCameraMake(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:Model"); err == nil {
|
||||
initPhoto()
|
||||
photo.SetCameraModel(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:FNumber"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initPhoto()
|
||||
photo.SetFNumber(i)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:FocalLength"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initPhoto()
|
||||
photo.SetFocalLength(i)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "Base ISO"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initPhoto()
|
||||
photo.SetIso(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "tiff:Orientation"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 0, 32); err == nil {
|
||||
initPhoto()
|
||||
photo.SetOrientation(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:DateTimeOriginal"); err == nil {
|
||||
layout := "2006-01-02T15:04:05"
|
||||
if t, err := time.Parse(layout, v); err == nil {
|
||||
initPhoto()
|
||||
photo.SetTakenDateTime(t)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "exif:ExposureTime"); err == nil {
|
||||
if i, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initPhoto()
|
||||
photo.SetExposureNumerator(1)
|
||||
photo.SetExposureDenominator(math.Round(1 / i))
|
||||
}
|
||||
}
|
||||
|
||||
return photo
|
||||
}
|
||||
|
||||
func (t Tika) getAudio(meta map[string][]string) *libregraph.Audio {
|
||||
var audio *libregraph.Audio
|
||||
initAudio := func() {
|
||||
if audio == nil {
|
||||
audio = libregraph.NewAudio()
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:album"); err == nil {
|
||||
initAudio()
|
||||
audio.SetAlbum(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:albumArtist"); err == nil {
|
||||
initAudio()
|
||||
audio.SetAlbumArtist(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:artist"); err == nil {
|
||||
initAudio()
|
||||
audio.SetArtist(v)
|
||||
}
|
||||
|
||||
// TODO: audio.Bitrate: not provided by tika
|
||||
// TODO: audio.Composers: not provided by tika
|
||||
// TODO: audio.Copyright: not provided by tika for audio files?
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:discNumber"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetDisc(int32(i))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TODO: audio.DiscCount: not provided by tika
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:duration"); err == nil {
|
||||
// Tika emits fractional seconds.
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initAudio()
|
||||
audio.SetDuration(int64(math.Round(f * 1000)))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:genre"); err == nil {
|
||||
initAudio()
|
||||
audio.SetGenre(v)
|
||||
}
|
||||
|
||||
// TODO: audio.HasDrm: not provided by tika
|
||||
// TODO: audio.IsVariableBitrate: not provided by tika
|
||||
|
||||
if v, err := getFirstValue(meta, "dc:title"); err == nil {
|
||||
initAudio()
|
||||
audio.SetTitle(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:trackNumber"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetTrack(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: audio.TrackCount: not provided by tika
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:releaseDate"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetYear(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
conf "github.com/qsfera/server/services/search/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
contentMocks "github.com/qsfera/server/services/search/pkg/content/mocks"
|
||||
)
|
||||
|
||||
var _ = Describe("Tika", func() {
|
||||
Describe("extract", func() {
|
||||
var (
|
||||
body string
|
||||
fullResponse string
|
||||
language string
|
||||
version string
|
||||
srv *httptest.Server
|
||||
tika *content.Tika
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
body = ""
|
||||
language = ""
|
||||
version = ""
|
||||
fullResponse = ""
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
out := ""
|
||||
switch req.URL.Path {
|
||||
case "/version":
|
||||
out = version
|
||||
case "/language/string":
|
||||
out = language
|
||||
case "/rmeta/text":
|
||||
if fullResponse != "" {
|
||||
out = fullResponse
|
||||
} else {
|
||||
out = fmt.Sprintf(`[{"X-TIKA:content":"%s"}]`, body)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(out))
|
||||
}))
|
||||
|
||||
cfg := conf.DefaultConfig()
|
||||
cfg.Extractor.Tika.TikaURL = srv.URL
|
||||
cfg.Extractor.Tika.CleanStopWords = true
|
||||
|
||||
var err error
|
||||
tika, err = content.NewTikaExtractor(nil, log.NewLogger(), cfg)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(tika).ToNot(BeNil())
|
||||
|
||||
retriever := &contentMocks.Retriever{}
|
||||
retriever.On("Retrieve", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader(body)), nil)
|
||||
|
||||
tika.Retriever = retriever
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
srv.Close()
|
||||
})
|
||||
|
||||
It("skips non file resources", func() {
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal(""))
|
||||
})
|
||||
|
||||
It("adds content", func() {
|
||||
body = "any body"
|
||||
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal(body))
|
||||
})
|
||||
|
||||
It("adds audio content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"xmpDM:genre": "Some Genre",
|
||||
"xmpDM:album": "Some Album",
|
||||
"xmpDM:trackNumber": "7",
|
||||
"xmpDM:discNumber": "4",
|
||||
"xmpDM:releaseDate": "2004",
|
||||
"xmpDM:artist": "Some Artist",
|
||||
"xmpDM:albumArtist": "Some AlbumArtist",
|
||||
"xmpDM:audioCompressor": "MP3",
|
||||
"xmpDM:audioChannelType": "Stereo",
|
||||
"version": "MPEG 3 Layer III Version 1",
|
||||
"xmpDM:logComment": "some comment",
|
||||
"xmpDM:audioSampleRate": "44100",
|
||||
"channels": "2",
|
||||
"dc:title": "Some Title",
|
||||
"xmpDM:duration": "225.5",
|
||||
"Content-Type": "audio/mpeg",
|
||||
"samplerate": "44100"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
audio := doc.Audio
|
||||
Expect(audio).ToNot(BeNil())
|
||||
|
||||
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
|
||||
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
|
||||
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
|
||||
// Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
|
||||
// Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
|
||||
// Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
|
||||
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(4)))
|
||||
// Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
|
||||
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225500)))
|
||||
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
|
||||
// Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
|
||||
// Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
|
||||
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
|
||||
Expect(audio.Track).To(Equal(libregraph.PtrInt32(7)))
|
||||
// Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
|
||||
Expect(audio.Year).To(Equal(libregraph.PtrInt32(2004)))
|
||||
|
||||
})
|
||||
|
||||
It("adds location content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"geo:lat": "49.48675890884328",
|
||||
"geo:long": "11.103870357204285"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
location := doc.Location
|
||||
Expect(location).ToNot(BeNil())
|
||||
|
||||
// TODO: Altitude is not supported right now
|
||||
Expect(location.Altitude).To(BeNil())
|
||||
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
|
||||
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
|
||||
})
|
||||
|
||||
It("adds image content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"tiff:ImageWidth": "100",
|
||||
"tiff:ImageLength": "100"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
image := doc.Image
|
||||
Expect(image).ToNot(BeNil())
|
||||
|
||||
Expect(image.Width).To(Equal(libregraph.PtrInt32(100)))
|
||||
Expect(image.Height).To(Equal(libregraph.PtrInt32(100)))
|
||||
})
|
||||
|
||||
It("adds photo content", func() {
|
||||
fullResponse = `[
|
||||
{
|
||||
"tiff:Make": "Canon",
|
||||
"tiff:Model": "Canon EOS 5D",
|
||||
"exif:ExposureTime": "0.001",
|
||||
"exif:FNumber": "1.8",
|
||||
"exif:FocalLength": "50",
|
||||
"Base ISO": "100",
|
||||
"tiff:Orientation": "1",
|
||||
"exif:DateTimeOriginal": "2018-01-01T12:34:56"
|
||||
}
|
||||
]`
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
photo := doc.Photo
|
||||
Expect(photo).ToNot(BeNil())
|
||||
|
||||
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
|
||||
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Canon EOS 5D")))
|
||||
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
|
||||
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(1000)))
|
||||
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
|
||||
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
|
||||
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(100)))
|
||||
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
|
||||
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
|
||||
})
|
||||
|
||||
It("removes stop words", func() {
|
||||
body = "body to test stop words!!! against almost everyone"
|
||||
language = "en"
|
||||
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal("body test stop words!!!"))
|
||||
})
|
||||
|
||||
It("keeps stop words", func() {
|
||||
body = "body to test stop words!!! against almost everyone"
|
||||
language = "en"
|
||||
|
||||
tika.CleanStopWords = false
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Size: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Content).To(Equal(body))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
Namespace = "qsfera"
|
||||
|
||||
// Subsystem defines the subsystem for the defines metrics.
|
||||
Subsystem = "search"
|
||||
|
||||
buildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "build_info",
|
||||
Help: "Build information",
|
||||
}, []string{"version"})
|
||||
eventsOutstandingAcks = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "events_outstanding_acks",
|
||||
Help: "Number of outstanding acks for events",
|
||||
})
|
||||
eventsUnprocessed = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "events_unprocessed",
|
||||
Help: "Number of unprocessed events",
|
||||
})
|
||||
eventsRedelivered = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "events_redelivered",
|
||||
Help: "Number of redelivered events",
|
||||
})
|
||||
searchDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "search_duration_seconds",
|
||||
Help: "Duration of search operations in seconds",
|
||||
Buckets: []float64{0.1, 0.5, 1, 2.5, 5, 10, 30, 60},
|
||||
}, []string{"status"})
|
||||
indexDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "index_duration_seconds",
|
||||
Help: "Duration of indexing operations in seconds",
|
||||
Buckets: []float64{0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600, 1200},
|
||||
}, []string{"status"})
|
||||
)
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
// Counter *prometheus.CounterVec
|
||||
BuildInfo *prometheus.GaugeVec
|
||||
EventsOutstandingAcks prometheus.Gauge
|
||||
EventsUnprocessed prometheus.Gauge
|
||||
EventsRedelivered prometheus.Gauge
|
||||
SearchDuration *prometheus.HistogramVec
|
||||
IndexDuration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{
|
||||
BuildInfo: buildInfo,
|
||||
EventsOutstandingAcks: eventsOutstandingAcks,
|
||||
EventsUnprocessed: eventsUnprocessed,
|
||||
EventsRedelivered: eventsRedelivered,
|
||||
SearchDuration: searchDuration,
|
||||
IndexDuration: indexDuration,
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
const defaultBatchSize = 50
|
||||
|
||||
var (
|
||||
ErrUnhealthyCluster = fmt.Errorf("cluster is not healthy")
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
index string
|
||||
client *opensearchgoAPI.Client
|
||||
}
|
||||
|
||||
func NewBackend(index string, client *opensearchgoAPI.Client) (*Backend, error) {
|
||||
pingResp, err := client.Ping(context.TODO(), &opensearchgoAPI.PingReq{})
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w, failed to ping opensearch: %w", ErrUnhealthyCluster, err)
|
||||
case pingResp.IsError():
|
||||
return nil, fmt.Errorf("%w, failed to ping opensearch", ErrUnhealthyCluster)
|
||||
}
|
||||
|
||||
// apply the index template
|
||||
if err := IndexManagerLatest.Apply(context.TODO(), index, client); err != nil {
|
||||
return nil, fmt.Errorf("failed to apply index template: %w", err)
|
||||
}
|
||||
|
||||
// first check if the cluster is healthy
|
||||
|
||||
resp, err := client.Cluster.Health(context.TODO(), &opensearchgoAPI.ClusterHealthReq{
|
||||
Indices: []string{index},
|
||||
Params: opensearchgoAPI.ClusterHealthParams{
|
||||
Local: opensearchgoAPI.ToPointer(true),
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w, failed to get cluster health: %w", ErrUnhealthyCluster, err)
|
||||
case resp.TimedOut:
|
||||
return nil, fmt.Errorf("%w, cluster health request timed out", ErrUnhealthyCluster)
|
||||
case resp.Status != "green" && resp.Status != "yellow":
|
||||
return nil, fmt.Errorf("%w, cluster health is not green or yellow: %s", ErrUnhealthyCluster, resp.Status)
|
||||
}
|
||||
|
||||
return &Backend{index: index, client: client}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
|
||||
boolQuery, err := convert.KQLToOpenSearchBoolQuery(sir.Query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert KQL query to OpenSearch bool query: %w", err)
|
||||
}
|
||||
|
||||
// filter out deleted resources
|
||||
boolQuery.Filter(
|
||||
osu.NewTermQuery[bool]("Deleted").Value(false),
|
||||
)
|
||||
|
||||
if sir.Ref != nil {
|
||||
// if a reference is provided, filter by the root ID
|
||||
boolQuery.Filter(
|
||||
osu.NewTermQuery[string]("RootID").Value(
|
||||
storagespace.FormatResourceID(
|
||||
&storageProvider.ResourceId{
|
||||
StorageId: sir.Ref.GetResourceId().GetStorageId(),
|
||||
SpaceId: sir.Ref.GetResourceId().GetSpaceId(),
|
||||
OpaqueId: sir.Ref.GetResourceId().GetOpaqueId(),
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
searchParams := opensearchgoAPI.SearchParams{
|
||||
SourceExcludes: []string{"Content"}, // Do not send back the full content in the search response, as it is only needed for highlighting and can be large. The highlighted snippets will be sent back in the response instead.
|
||||
}
|
||||
|
||||
switch {
|
||||
case sir.PageSize == -1:
|
||||
searchParams.Size = conversions.ToPointer(1000)
|
||||
case sir.PageSize == 0:
|
||||
searchParams.Size = conversions.ToPointer(200)
|
||||
default:
|
||||
searchParams.Size = conversions.ToPointer(int(sir.PageSize))
|
||||
}
|
||||
|
||||
req, err := osu.BuildSearchReq(&opensearchgoAPI.SearchReq{
|
||||
Indices: []string{b.index},
|
||||
Params: searchParams,
|
||||
},
|
||||
boolQuery,
|
||||
osu.SearchBodyParams{
|
||||
Highlight: &osu.BodyParamHighlight{
|
||||
HighlightOptions: osu.HighlightOptions{
|
||||
NumberOfFragments: 2,
|
||||
PreTags: []string{"<mark>"},
|
||||
PostTags: []string{"</mark>"},
|
||||
},
|
||||
Fields: map[string]osu.HighlightOptions{
|
||||
"Content": {
|
||||
Type: osu.HighlightTypeFvh,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build search request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := b.client.Search(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search: %w", err)
|
||||
}
|
||||
|
||||
matches := make([]*searchMessage.Match, 0, len(resp.Hits.Hits))
|
||||
totalMatches := resp.Hits.Total.Value
|
||||
for _, hit := range resp.Hits.Hits {
|
||||
match, err := convert.OpenSearchHitToMatch(hit)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
return &searchService.SearchIndexResponse{
|
||||
Matches: matches,
|
||||
TotalMatches: int32(totalMatches),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Backend) DocCount() (uint64, error) {
|
||||
req, err := osu.BuildIndicesCountReq(
|
||||
&opensearchgoAPI.IndicesCountReq{
|
||||
Indices: []string{b.index},
|
||||
},
|
||||
osu.NewTermQuery[bool]("Deleted").Value(false),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to build count request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := b.client.Indices.Count(context.TODO(), req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count documents: %w", err)
|
||||
}
|
||||
|
||||
return uint64(resp.Count), nil
|
||||
}
|
||||
|
||||
func (b *Backend) Upsert(id string, r search.Resource) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Upsert(id, r); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Move(id string, parentID string, target string) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Move(id, parentID, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Delete(id string) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Restore(id string) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Restore(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) Purge(id string, onlyDeleted bool) error {
|
||||
batch, err := b.NewBatch(defaultBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := batch.Purge(id, onlyDeleted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return batch.Push()
|
||||
}
|
||||
|
||||
func (b *Backend) NewBatch(size int) (search.BatchOperator, error) {
|
||||
return NewBatch(b.client, b.index, size)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package opensearch_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
func TestNewBackend(t *testing.T) {
|
||||
t.Run("fails to create if the cluster is not healthy", func(t *testing.T) {
|
||||
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{
|
||||
Client: opensearchgo.Config{
|
||||
Addresses: []string{"http://localhost:1025"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "failed to create OpenSearch client")
|
||||
|
||||
backend, err := opensearch.NewBackend("test-engine-new-engine", client)
|
||||
require.Nil(t, backend)
|
||||
require.ErrorIs(t, err, opensearch.ErrUnhealthyCluster)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_Search(t *testing.T) {
|
||||
indexName := "qsfera-test-engine-search"
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
defer tc.Require.IndicesDelete([]string{indexName})
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
|
||||
t.Run("most simple search", func(t *testing.T) {
|
||||
resp, err := backend.Search(t.Context(), &searchService.SearchIndexRequest{
|
||||
Query: fmt.Sprintf(`"%s"`, document.Name),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Matches, 1)
|
||||
require.Equal(t, int32(1), resp.TotalMatches)
|
||||
require.Equal(t, document.ID, fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId))
|
||||
})
|
||||
|
||||
t.Run("ignores files that are marked as deleted", func(t *testing.T) {
|
||||
deletedDocument := opensearchtest.Testdata.Resources.File
|
||||
deletedDocument.ID = "1$2!4"
|
||||
deletedDocument.Deleted = true
|
||||
|
||||
tc.Require.DocumentCreate(indexName, deletedDocument.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, deletedDocument)))
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 2)
|
||||
|
||||
resp, err := backend.Search(t.Context(), &searchService.SearchIndexRequest{
|
||||
Query: fmt.Sprintf(`"%s"`, document.Name),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.Matches, 1)
|
||||
require.Equal(t, int32(1), resp.TotalMatches)
|
||||
require.Equal(t, document.ID, fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_Upsert(t *testing.T) {
|
||||
indexName := "qsfera-test-engine-upsert"
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
defer tc.Require.IndicesDelete([]string{indexName})
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("upsert with full document", func(t *testing.T) {
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
require.NoError(t, backend.Upsert(document.ID, document))
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_Move(t *testing.T) {
|
||||
indexName := "qsfera-test-engine-move"
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
defer tc.Require.IndicesDelete([]string{indexName})
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("moves the document to a new path", func(t *testing.T) {
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
|
||||
body := opensearchtest.JSONMustMarshal(t, map[string]any{
|
||||
"query": map[string]any{
|
||||
"ids": map[string]any{
|
||||
"values": []string{document.ID},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
resources := opensearchtest.SearchHitsMustBeConverted[search.Resource](t, tc.Require.Search(indexName, strings.NewReader(body)).Hits)
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, document.Path, resources[0].Path)
|
||||
|
||||
document.Path = "./new/path/to/resource"
|
||||
require.NoError(t, backend.Move(document.ID, document.ParentID, document.Path))
|
||||
|
||||
resources = opensearchtest.SearchHitsMustBeConverted[search.Resource](t, tc.Require.Search(indexName, strings.NewReader(body)).Hits)
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, document.Path, resources[0].Path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_Delete(t *testing.T) {
|
||||
indexName := "qsfera-test-engine-delete"
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
defer tc.Require.IndicesDelete([]string{indexName})
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("mark document as deleted", func(t *testing.T) {
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
|
||||
body := opensearchtest.JSONMustMarshal(t, map[string]any{
|
||||
"query": map[string]any{
|
||||
"term": map[string]any{
|
||||
"Deleted": map[string]any{
|
||||
"value": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 0)
|
||||
|
||||
require.NoError(t, backend.Delete(document.ID))
|
||||
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_Restore(t *testing.T) {
|
||||
indexName := "qsfera-test-engine-restore"
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
defer tc.Require.IndicesDelete([]string{indexName})
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("mark document as not deleted", func(t *testing.T) {
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
document.Deleted = true
|
||||
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
|
||||
body := opensearchtest.JSONMustMarshal(t, map[string]any{
|
||||
"query": map[string]any{
|
||||
"term": map[string]any{
|
||||
"Deleted": map[string]any{
|
||||
"value": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 1)
|
||||
|
||||
require.NoError(t, backend.Restore(document.ID))
|
||||
tc.Require.IndicesCount([]string{indexName}, strings.NewReader(body), 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_Purge(t *testing.T) {
|
||||
indexName := "qsfera-test-engine-purge"
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
defer tc.Require.IndicesDelete([]string{indexName})
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("purge with full document", func(t *testing.T) {
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
|
||||
require.NoError(t, backend.Purge(document.ID, false))
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
})
|
||||
|
||||
t.Run("purge resource trees", func(t *testing.T) {
|
||||
resourceFolder := opensearchtest.Testdata.Resources.Folder
|
||||
tc.Require.DocumentCreate(indexName, resourceFolder.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFolder)))
|
||||
|
||||
resourceFile := opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, resourceFile.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFile)))
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 2)
|
||||
|
||||
require.NoError(t, backend.Purge(resourceFolder.ID, false))
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
})
|
||||
|
||||
t.Run("purge resource trees and ignores undeleted resources", func(t *testing.T) {
|
||||
resourceFolder := opensearchtest.Testdata.Resources.Folder
|
||||
tc.Require.DocumentCreate(indexName, resourceFolder.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFolder)))
|
||||
|
||||
resourceFile := opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, resourceFile.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, resourceFile)))
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 2)
|
||||
|
||||
require.NoError(t, backend.Delete(resourceFile.ID))
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
require.NoError(t, backend.Purge(resourceFolder.ID, true))
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEngine_DocCount(t *testing.T) {
|
||||
indexName := "qsfera-test-engine-doc-count"
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
defer tc.Require.IndicesDelete([]string{indexName})
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("ignore deleted documents", func(t *testing.T) {
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, document)))
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
|
||||
count, err := backend.DocCount()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(1), count)
|
||||
|
||||
tc.Require.Update(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(t, map[string]any{
|
||||
"doc": map[string]any{
|
||||
"Deleted": true,
|
||||
},
|
||||
})))
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
|
||||
count, err = backend.DocCount()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(0), count)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
var _ search.BatchOperator = (*Batch)(nil) // ensure Batch implements BatchOperator
|
||||
|
||||
type Batch struct {
|
||||
client *opensearchgoAPI.Client
|
||||
index string
|
||||
size int
|
||||
log log.Logger
|
||||
operations []any
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewBatch(client *opensearchgoAPI.Client, index string, size int) (*Batch, error) {
|
||||
if size <= 0 {
|
||||
return nil, errors.New("batch size must be greater than 0")
|
||||
}
|
||||
|
||||
return &Batch{
|
||||
client: client,
|
||||
size: size,
|
||||
index: index,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Batch) Upsert(id string, r search.Resource) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
body, err := conversions.To[map[string]any](r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal resource: %w", err)
|
||||
}
|
||||
|
||||
op := func() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"index": map[string]any{"_index": b.index, "_id": id}},
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.operations = append(b.operations, op)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
`,
|
||||
Lang: "painless",
|
||||
Params: map[string]any{
|
||||
"id": id,
|
||||
"parentID": parentID,
|
||||
"oldPath": rootResource.Path,
|
||||
"newPath": utils.MakeRelativePath(location),
|
||||
"newName": path.Base(utils.MakeRelativePath(location)),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.operations = append(b.operations, op)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Delete(id string) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
op := func() error {
|
||||
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(_ search.Resource) *osu.BodyParamScript {
|
||||
return &osu.BodyParamScript{
|
||||
Source: "ctx._source.Deleted = params.deleted",
|
||||
Lang: "painless",
|
||||
Params: map[string]any{
|
||||
"deleted": true,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.operations = append(b.operations, op)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Restore(id string) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
op := func() error {
|
||||
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(_ search.Resource) *osu.BodyParamScript {
|
||||
return &osu.BodyParamScript{
|
||||
Source: "ctx._source.Deleted = params.deleted",
|
||||
Lang: "painless",
|
||||
Params: map[string]any{
|
||||
"deleted": false,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.operations = append(b.operations, op)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Purge(id string, onlyDeleted bool) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
resource, err := searchResourceByID(context.Background(), b.client, b.index, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get resource: %w", err)
|
||||
}
|
||||
|
||||
query := osu.NewBoolQuery().Must(osu.NewTermQuery[string]("Path").Value(resource.Path))
|
||||
if onlyDeleted {
|
||||
query.Must(osu.NewTermQuery[bool]("Deleted").Value(true))
|
||||
}
|
||||
|
||||
req, err := osu.BuildDocumentDeleteByQueryReq(
|
||||
opensearchgoAPI.DocumentDeleteByQueryReq{
|
||||
Indices: []string{b.index},
|
||||
Params: opensearchgoAPI.DocumentDeleteByQueryParams{
|
||||
WaitForCompletion: conversions.ToPointer(true),
|
||||
},
|
||||
},
|
||||
query,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build delete by query request: %w", err)
|
||||
}
|
||||
|
||||
op := func() error {
|
||||
resp, err := b.client.Document.DeleteByQuery(context.TODO(), req)
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to delete by query: %w", err)
|
||||
case len(resp.Failures) != 0:
|
||||
return fmt.Errorf("failed to delete by query, failures: %v", resp.Failures)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.operations = append(b.operations, op)
|
||||
b.mu.Unlock()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Batch) Push() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
defer func() { // cleanup
|
||||
b.operations = nil
|
||||
}()
|
||||
|
||||
var bulkOperations []map[string]any
|
||||
pushBulkOperations := func() error {
|
||||
if len(bulkOperations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var body strings.Builder
|
||||
for _, operation := range bulkOperations {
|
||||
part, err := json.Marshal(operation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal bulk operation: %w", err)
|
||||
}
|
||||
body.Write(part)
|
||||
body.WriteString("\n")
|
||||
}
|
||||
|
||||
if _, err := b.client.Bulk(context.Background(), opensearchgoAPI.BulkReq{
|
||||
Body: strings.NewReader(body.String()),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to execute bulk operations: %w", err)
|
||||
}
|
||||
|
||||
bulkOperations = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep the order of operations in the batch intact,
|
||||
// unfortunately, operations like DeleteByQuery cannot be part of the bulk API,
|
||||
// so we need to push the previous bulk operations before executing such operations
|
||||
// this might lead to smaller bulks than the configured size, but ensures correct order
|
||||
for _, operation := range b.operations {
|
||||
switch op := operation.(type) {
|
||||
case func() []map[string]any:
|
||||
bulkOperations = append(bulkOperations, op()...)
|
||||
case func() error:
|
||||
if err := pushBulkOperations(); err != nil {
|
||||
return fmt.Errorf("failed to push operations: %w", err)
|
||||
}
|
||||
if err := op(); err != nil {
|
||||
return fmt.Errorf("failed to execute operation: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pushBulkOperations()
|
||||
}
|
||||
|
||||
func (b *Batch) withSizeLimit(f func() error) error {
|
||||
if err := f(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(b.operations) >= b.size {
|
||||
return b.Push()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"reflect"
|
||||
|
||||
"github.com/go-jose/go-jose/v3/json"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrManualActionRequired = errors.New("manual action required")
|
||||
IndexManagerLatest = IndexIndexManagerResourceV2
|
||||
IndexIndexManagerResourceV1 IndexManager = "resource_v1.json"
|
||||
IndexIndexManagerResourceV2 IndexManager = "resource_v2.json"
|
||||
)
|
||||
|
||||
//go:embed internal/indexes/*.json
|
||||
var indexes embed.FS
|
||||
|
||||
type IndexManager string
|
||||
|
||||
func (m IndexManager) String() string {
|
||||
b, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (m IndexManager) Apply(ctx context.Context, name string, client *opensearchgoAPI.Client) error {
|
||||
localIndexB, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal index %s: %w", name, err)
|
||||
}
|
||||
|
||||
indicesExistsResp, err := client.Indices.Exists(ctx, opensearchgoAPI.IndicesExistsReq{
|
||||
Indices: []string{name},
|
||||
})
|
||||
switch {
|
||||
case indicesExistsResp != nil && indicesExistsResp.StatusCode == 404:
|
||||
break
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to check if index %s exists: %w", name, err)
|
||||
case indicesExistsResp == nil:
|
||||
return fmt.Errorf("indicesExistsResp is nil for index %s", name)
|
||||
}
|
||||
|
||||
if indicesExistsResp.StatusCode == 200 {
|
||||
resp, err := client.Indices.Get(ctx, opensearchgoAPI.IndicesGetReq{
|
||||
Indices: []string{name},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get index %s: %w", name, err)
|
||||
}
|
||||
|
||||
remoteIndex, ok := resp.Indices[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("index %s not found in response", name)
|
||||
}
|
||||
remoteIndexB, err := json.Marshal(remoteIndex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal index %s: %w", name, err)
|
||||
}
|
||||
|
||||
localIndexJson := gjson.ParseBytes(localIndexB)
|
||||
remoteIndexJson := gjson.ParseBytes(remoteIndexB)
|
||||
|
||||
compare := func(lvPath, rvPath string) (any, any, bool) {
|
||||
lv := localIndexJson.Get(lvPath).Raw
|
||||
rv := remoteIndexJson.Get(rvPath).Raw
|
||||
|
||||
var lvv, rvv any
|
||||
if err := json.Unmarshal([]byte(lv), &lvv); err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(rv), &rvv); err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
return lv, rv, reflect.DeepEqual(lvv, rvv)
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
for k := range localIndexJson.Get("settings").Map() {
|
||||
if lv, rv, ok := compare("settings."+k, "settings.index."+k); !ok {
|
||||
errs = append(errs, fmt.Errorf("settings.%s local %s, remote %s", k, lv, rv))
|
||||
}
|
||||
}
|
||||
|
||||
for k := range localIndexJson.Get("mappings.properties").Map() {
|
||||
if _, _, ok := compare("mappings.properties."+k, "mappings.properties."+k); !ok {
|
||||
errs = append(errs, fmt.Errorf("mappings.properties.%s", k))
|
||||
}
|
||||
}
|
||||
|
||||
if errs != nil {
|
||||
return fmt.Errorf(
|
||||
"index %s already exists and is different from the requested version, %w: %w",
|
||||
name,
|
||||
ErrManualActionRequired,
|
||||
errors.Join(errs...),
|
||||
)
|
||||
}
|
||||
|
||||
return nil // Index is already up to date, no action needed
|
||||
}
|
||||
|
||||
createResp, err := client.Indices.Create(ctx, opensearchgoAPI.IndicesCreateReq{
|
||||
Index: name,
|
||||
Body: bytes.NewReader(localIndexB),
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to create index %s: %w", name, err)
|
||||
case !createResp.Acknowledged:
|
||||
return fmt.Errorf("failed to create index %s: not acknowledged", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package opensearch_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestIndexManager(t *testing.T) {
|
||||
t.Run("index plausibility", func(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[opensearch.IndexManager, struct{}]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: opensearch.IndexManagerLatest,
|
||||
},
|
||||
}
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
indexName := "qsfera-test-resource"
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := test.Got.MarshalJSON()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, body)
|
||||
require.NotEmpty(t, test.Got.String())
|
||||
require.JSONEq(t, test.Got.String(), string(body))
|
||||
require.NoError(t, test.Got.Apply(t.Context(), indexName, tc.Client()))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not create index if it already exists and is up to date", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "qsfera-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(indexManager.String()))
|
||||
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client()))
|
||||
})
|
||||
|
||||
t.Run("fails to create index if it already exists but is not up to date", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "qsfera-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := sjson.Set(indexManager.String(), "settings.number_of_shards", "2")
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client()), opensearch.ErrManualActionRequired)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/qsfera/server/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)
|
||||
}
|
||||
|
||||
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
|
||||
"rootid": "RootID",
|
||||
"path": "Path",
|
||||
"id": "ID",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
"mtime": "Mtime",
|
||||
"mediatype": "MimeType",
|
||||
"type": "Type",
|
||||
"tag": "Tags",
|
||||
"tags": "Tags",
|
||||
"content": "Content",
|
||||
"hidden": "Hidden",
|
||||
}[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{"Hidden"}, key) {
|
||||
return value // ignore certain keys and return the original value
|
||||
}
|
||||
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func (_ kqlExpander) unfoldValue(key, value string) []ast.Node {
|
||||
result, ok := map[string][]ast.Node{
|
||||
"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.oasis.opendocument.spreadshee"},
|
||||
&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
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
package convert_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
|
||||
|
||||
"github.com/qsfera/server/pkg/ast"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
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",
|
||||
"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: "!Hidden: StringNode -> stringnode",
|
||||
Got: []ast.Node{
|
||||
ast.StringNode{Key: "aBc", Value: "StringNode"},
|
||||
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
|
||||
ast.StringNode{Key: "aBc", Value: "StringNode"},
|
||||
}},
|
||||
},
|
||||
Want: []ast.Node{
|
||||
&ast.StringNode{Key: "aBc", Value: "stringnode"},
|
||||
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "aBc", Value: "stringnode"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Hidden: StringNode -> StringNode",
|
||||
Got: []ast.Node{
|
||||
ast.StringNode{Key: "Hidden", Value: "StringNode"},
|
||||
ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
|
||||
ast.StringNode{Key: "Hidden", Value: "StringNode"},
|
||||
}},
|
||||
},
|
||||
Want: []ast.Node{
|
||||
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
|
||||
&ast.GroupNode{Key: "GroupNode", Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Hidden", Value: "StringNode"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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.oasis.opendocument.spreadshee"},
|
||||
&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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/qsfera/server/pkg/kql"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedNodeType = fmt.Errorf("unsupported node type")
|
||||
)
|
||||
|
||||
func KQLToOpenSearchBoolQuery(kqlQuery string) (*osu.BoolQuery, error) {
|
||||
kqlAst, err := kql.Builder{}.Build(kqlQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
kqlNodes, err := ExpandKQL(kqlAst.Nodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to expand KQL AST nodes: %w", err)
|
||||
}
|
||||
|
||||
builder, err := TranspileKQLToOpenSearch(kqlNodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile query: %w", err)
|
||||
}
|
||||
|
||||
if q, ok := builder.(*osu.BoolQuery); !ok {
|
||||
return osu.NewBoolQuery().Must(builder), nil
|
||||
} else {
|
||||
return q, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package convert_test
|
||||
@@ -0,0 +1,147 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/qsfera/server/pkg/ast"
|
||||
"github.com/qsfera/server/pkg/kql"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
)
|
||||
|
||||
func TranspileKQLToOpenSearch(nodes []ast.Node) (osu.Builder, error) {
|
||||
return kqlOpensearchTranspiler{}.Transpile(nodes)
|
||||
}
|
||||
|
||||
type kqlOpensearchTranspiler struct{}
|
||||
|
||||
func (t kqlOpensearchTranspiler) Transpile(nodes []ast.Node) (osu.Builder, error) {
|
||||
q, err := t.transpile(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error) {
|
||||
if len(nodes) == 0 {
|
||||
return nil, fmt.Errorf("no nodes to compile")
|
||||
}
|
||||
|
||||
if len(nodes) == 1 {
|
||||
builder, err := t.toBuilder(nodes[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get builder for single node: %w", err)
|
||||
}
|
||||
return builder, nil
|
||||
}
|
||||
|
||||
boolQueryParams := &osu.BoolQueryParams{}
|
||||
boolQuery := osu.NewBoolQuery().Params(boolQueryParams)
|
||||
boolQueryAdd := boolQuery.Must
|
||||
for i, node := range nodes {
|
||||
nextOp := t.getOperatorValueAt(nodes, i+1)
|
||||
prevOp := t.getOperatorValueAt(nodes, i-1)
|
||||
|
||||
switch {
|
||||
case nextOp == kql.BoolOR:
|
||||
boolQueryAdd = boolQuery.Should
|
||||
case nextOp == kql.BoolAND:
|
||||
boolQueryAdd = boolQuery.Must
|
||||
case prevOp == kql.BoolNOT:
|
||||
boolQueryAdd = boolQuery.MustNot
|
||||
}
|
||||
|
||||
builder, err := t.toBuilder(node)
|
||||
switch {
|
||||
// if the node is not known, we skip it, such as an operator node
|
||||
case errors.Is(err, ErrUnsupportedNodeType):
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("failed to get builder for node %T: %w", node, err)
|
||||
}
|
||||
|
||||
if _, ok := node.(*ast.OperatorNode); ok {
|
||||
// operatorNodes are not builders, so we skip them
|
||||
continue
|
||||
}
|
||||
|
||||
if nextOp == kql.BoolOR {
|
||||
// if there are should clauses, we set the minimum should match to 1
|
||||
boolQueryParams.MinimumShouldMatch = 1
|
||||
}
|
||||
|
||||
boolQueryAdd(builder)
|
||||
}
|
||||
|
||||
return boolQuery, nil
|
||||
}
|
||||
|
||||
func (t kqlOpensearchTranspiler) getOperatorValueAt(nodes []ast.Node, i int) string {
|
||||
if i < 0 || i >= len(nodes) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if opn, ok := nodes[i].(*ast.OperatorNode); ok {
|
||||
return opn.Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
|
||||
var builder osu.Builder
|
||||
|
||||
switch node := node.(type) {
|
||||
case *ast.BooleanNode:
|
||||
return osu.NewTermQuery[bool](node.Key).Value(node.Value), nil
|
||||
case *ast.StringNode:
|
||||
isWildcard := strings.Contains(node.Value, "*")
|
||||
if isWildcard {
|
||||
return osu.NewWildcardQuery(node.Key).Value(node.Value), nil
|
||||
}
|
||||
|
||||
totalTerms := strings.Split(node.Value, " ")
|
||||
isSingleTerm := len(totalTerms) == 1
|
||||
isMultiTerm := len(totalTerms) >= 1
|
||||
switch {
|
||||
case isSingleTerm:
|
||||
return osu.NewTermQuery[string](node.Key).Value(node.Value), nil
|
||||
case isMultiTerm:
|
||||
return osu.NewMatchPhraseQuery(node.Key).Query(node.Value), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported string node value: %s", node.Value)
|
||||
case *ast.DateTimeNode:
|
||||
if node.Operator == nil {
|
||||
return builder, 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)
|
||||
case *ast.GroupNode:
|
||||
group, err := t.transpile(node.Nodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build group: %w", err)
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %T", ErrUnsupportedNodeType, node)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package convert_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/pkg/ast"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestTranspileKQLToOpenSearch(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[*ast.Ast, osu.Builder]{
|
||||
// kql to os dsl - type tests
|
||||
{
|
||||
Name: "term query - string node",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "qsfera"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[string]("Name").Value("qsfera"),
|
||||
},
|
||||
{
|
||||
Name: "term query - boolean node - true",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.BooleanNode{Key: "Deleted", Value: true},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[bool]("Deleted").Value(true),
|
||||
},
|
||||
{
|
||||
Name: "term query - boolean node - false",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.BooleanNode{Key: "Deleted", Value: false},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[bool]("Deleted").Value(false),
|
||||
},
|
||||
{
|
||||
Name: "match-phrase query - string node",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "open cloud"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewMatchPhraseQuery("Name").Query(`open cloud`),
|
||||
},
|
||||
{
|
||||
Name: "wildcard query - string node",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "open*"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewWildcardQuery("Name").Value("open*"),
|
||||
},
|
||||
{
|
||||
Name: "bool query",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "a"},
|
||||
&ast.StringNode{Key: "Name", Value: "b"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().Must(
|
||||
osu.NewTermQuery[string]("Name").Value("a"),
|
||||
osu.NewTermQuery[string]("Name").Value("b"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "no bool query for single term",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "any"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[string]("Name").Value("any"),
|
||||
},
|
||||
{
|
||||
Name: "range query >",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.DateTimeNode{
|
||||
Key: "Mtime",
|
||||
Operator: &ast.OperatorNode{Value: ">"},
|
||||
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Want: osu.NewRangeQuery[time.Time]("Mtime").Gt(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
|
||||
},
|
||||
{
|
||||
Name: "range query >=",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.DateTimeNode{
|
||||
Key: "Mtime",
|
||||
Operator: &ast.OperatorNode{Value: ">="},
|
||||
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Want: osu.NewRangeQuery[time.Time]("Mtime").Gte(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
|
||||
},
|
||||
{
|
||||
Name: "range query <",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.DateTimeNode{
|
||||
Key: "Mtime",
|
||||
Operator: &ast.OperatorNode{Value: "<"},
|
||||
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Want: osu.NewRangeQuery[time.Time]("Mtime").Lt(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
|
||||
},
|
||||
{
|
||||
Name: "range query <=",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.DateTimeNode{
|
||||
Key: "Mtime",
|
||||
Operator: &ast.OperatorNode{Value: "<="},
|
||||
Value: opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Want: osu.NewRangeQuery[time.Time]("Mtime").Lte(opensearchtest.TimeMustParse(t, "2023-09-05T08:42:11.23554+02:00")),
|
||||
},
|
||||
// kql to os dsl - structure tests
|
||||
{
|
||||
Name: "[*]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "qsfera"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[string]("Name").Value("qsfera"),
|
||||
},
|
||||
{
|
||||
Name: "[* *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "qsfera"},
|
||||
&ast.StringNode{Key: "age", Value: "32"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Must(
|
||||
osu.NewTermQuery[string]("Name").Value("qsfera"),
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* AND *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "qsfera"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "age", Value: "32"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Must(
|
||||
osu.NewTermQuery[string]("Name").Value("qsfera"),
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* OR *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "qsfera"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "age", Value: "32"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
|
||||
Should(
|
||||
osu.NewTermQuery[string]("Name").Value("qsfera"),
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[NOT *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "age", Value: "32"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
MustNot(
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* NOT *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "qsfera"},
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "age", Value: "32"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Must(
|
||||
osu.NewTermQuery[string]("Name").Value("qsfera"),
|
||||
).
|
||||
MustNot(
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* OR * OR *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "qsfera"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "age", Value: "32"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "age", Value: "44"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
|
||||
Should(
|
||||
osu.NewTermQuery[string]("Name").Value("qsfera"),
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
osu.NewTermQuery[string]("age").Value("44"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* AND * OR *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "a", Value: "a"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "b", Value: "b"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "c", Value: "c"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
|
||||
Must(
|
||||
osu.NewTermQuery[string]("a").Value("a"),
|
||||
).
|
||||
Should(
|
||||
osu.NewTermQuery[string]("b").Value("b"),
|
||||
osu.NewTermQuery[string]("c").Value("c"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* OR * AND *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "a", Value: "a"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "b", Value: "b"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "c", Value: "c"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
|
||||
Must(
|
||||
osu.NewTermQuery[string]("b").Value("b"),
|
||||
osu.NewTermQuery[string]("c").Value("c"),
|
||||
).
|
||||
Should(
|
||||
osu.NewTermQuery[string]("a").Value("a"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* OR * AND *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "a", Value: "a"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "b", Value: "b"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "c", Value: "c"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
|
||||
Should(
|
||||
osu.NewTermQuery[string]("a").Value("a"),
|
||||
).
|
||||
Must(
|
||||
osu.NewTermQuery[string]("b").Value("b"),
|
||||
osu.NewTermQuery[string]("c").Value("c"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[[* OR * OR *] AND *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "a", Value: "a"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "b", Value: "b"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "c", Value: "c"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "d", Value: "d"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Must(
|
||||
osu.NewBoolQuery().
|
||||
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
|
||||
Should(
|
||||
osu.NewTermQuery[string]("a").Value("a"),
|
||||
osu.NewTermQuery[string]("b").Value("b"),
|
||||
osu.NewTermQuery[string]("c").Value("c"),
|
||||
),
|
||||
osu.NewTermQuery[string]("d").Value("d"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[[* OR * OR *] AND NOT *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "a", Value: "a"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "b", Value: "b"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "c", Value: "c"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "d", Value: "d"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
Must(
|
||||
osu.NewBoolQuery().
|
||||
Params(&osu.BoolQueryParams{MinimumShouldMatch: 1}).
|
||||
Should(
|
||||
osu.NewTermQuery[string]("a").Value("a"),
|
||||
osu.NewTermQuery[string]("b").Value("b"),
|
||||
osu.NewTermQuery[string]("c").Value("c"),
|
||||
),
|
||||
).MustNot(
|
||||
osu.NewTermQuery[string]("d").Value("d"),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
if test.Skip {
|
||||
t.Skip("skipping test: " + test.Name)
|
||||
}
|
||||
|
||||
dsl, err := convert.TranspileKQLToOpenSearch(test.Got.Nodes)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, dsl))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, error) {
|
||||
resource, err := conversions.To[search.Resource](hit.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert hit source: %w", err)
|
||||
}
|
||||
|
||||
resourceRootID, err := storagespace.ParseID(resource.RootID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resourceID, err := storagespace.ParseID(resource.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resourceParentID, _ := storagespace.ParseID(resource.ParentID)
|
||||
|
||||
match := &searchMessage.Match{
|
||||
Score: hit.Score,
|
||||
Entity: &searchMessage.Entity{
|
||||
Ref: &searchMessage.Reference{
|
||||
ResourceId: &searchMessage.ResourceID{
|
||||
StorageId: resourceRootID.GetStorageId(),
|
||||
SpaceId: resourceRootID.GetSpaceId(),
|
||||
OpaqueId: resourceRootID.GetOpaqueId(),
|
||||
},
|
||||
Path: resource.Path,
|
||||
},
|
||||
Id: &searchMessage.ResourceID{
|
||||
StorageId: resourceID.GetStorageId(),
|
||||
SpaceId: resourceID.GetSpaceId(),
|
||||
OpaqueId: resourceID.GetOpaqueId(),
|
||||
},
|
||||
Name: resource.Name,
|
||||
ParentId: &searchMessage.ResourceID{
|
||||
StorageId: resourceParentID.GetStorageId(),
|
||||
SpaceId: resourceParentID.GetSpaceId(),
|
||||
OpaqueId: resourceParentID.GetOpaqueId(),
|
||||
},
|
||||
Size: resource.Size,
|
||||
Type: resource.Type,
|
||||
MimeType: resource.MimeType,
|
||||
Deleted: resource.Deleted,
|
||||
Tags: resource.Tags,
|
||||
Highlights: func() string {
|
||||
contentHighlights, ok := hit.Highlight["Content"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
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
|
||||
}(),
|
||||
},
|
||||
}
|
||||
|
||||
if mtime, err := time.Parse(time.RFC3339, resource.Mtime); err == nil {
|
||||
match.Entity.LastModifiedTime = ×tamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
|
||||
}
|
||||
|
||||
return match, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package convert_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
searchMessage "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/convert"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestOpenSearchHitToMatch(t *testing.T) {
|
||||
resource := opensearchtest.Testdata.Resources.File
|
||||
resource.MimeType = "audio/anything"
|
||||
|
||||
hit := opensearchgoAPI.SearchHit{
|
||||
Score: 1.1,
|
||||
Source: json.RawMessage(opensearchtest.JSONMustMarshal(t, resource)),
|
||||
}
|
||||
match, err := convert.OpenSearchHitToMatch(hit)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, hit.Score, match.Score)
|
||||
assert.Equal(t, resource.Name, match.Entity.Name)
|
||||
t.Parallel()
|
||||
t.Run("converts the audio field to the expected type", func(t *testing.T) {
|
||||
// searchMessage.Audio contains int64, int32 ... values that are converted to strings by the JSON marshaler,
|
||||
// so we need to convert the resource.Audio to align the expectations for the JSON comparison.
|
||||
audio, err := conversions.To[*searchMessage.Audio](resource.Audio)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, resource.Audio.Bitrate, match.Entity.Audio.Bitrate)
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, audio), opensearchtest.JSONMustMarshal(t, match.Entity.Audio))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"dario.cat/mergo"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
)
|
||||
|
||||
type Builder interface {
|
||||
json.Marshaler
|
||||
Map() (map[string]any, error)
|
||||
}
|
||||
|
||||
func newBase(v ...any) (map[string]any, error) {
|
||||
base := make(map[string]any)
|
||||
for _, value := range v {
|
||||
data, err := conversions.To[map[string]any](value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert value to map: %w", err)
|
||||
}
|
||||
|
||||
if isEmpty(data) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := mergo.Merge(&base, data); err != nil {
|
||||
return nil, fmt.Errorf("failed to merge value into base: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func applyValue[T any](target map[string]any, key string, v T) {
|
||||
if target == nil || isEmpty(key) || isEmpty(v) {
|
||||
return
|
||||
}
|
||||
|
||||
target[key] = v
|
||||
}
|
||||
|
||||
func applyValues[T any](target map[string]any, values map[string]T) {
|
||||
if target == nil || isEmpty(values) {
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range values {
|
||||
applyValue[T](target, k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func applyBuilder(target map[string]any, key string, builder Builder) error {
|
||||
if target == nil || isEmpty(key) || isEmpty(builder) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := builder.Map()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to map builder %s: %w", key, err)
|
||||
}
|
||||
|
||||
if !isEmpty(data) {
|
||||
target[key] = data
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyBuilders(target map[string]any, key string, bs ...Builder) error {
|
||||
if target == nil || isEmpty(key) || isEmpty(bs) {
|
||||
return nil
|
||||
}
|
||||
|
||||
builders := make([]map[string]any, 0, len(bs))
|
||||
for _, builder := range bs {
|
||||
data, err := builder.Map()
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to map builder %s: %w", key, err)
|
||||
case isEmpty(data):
|
||||
continue
|
||||
default:
|
||||
builders = append(builders, data)
|
||||
}
|
||||
}
|
||||
|
||||
if len(builders) > 0 {
|
||||
target[key] = builders
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEmpty(x any) bool {
|
||||
switch {
|
||||
case x == nil:
|
||||
return true
|
||||
case reflect.ValueOf(x).Kind() == reflect.Bool:
|
||||
return false
|
||||
case reflect.DeepEqual(x, reflect.Zero(reflect.TypeOf(x)).Interface()):
|
||||
return true
|
||||
case reflect.ValueOf(x).Kind() == reflect.Map && reflect.ValueOf(x).Len() == 0:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func merge[T any](vals ...T) T {
|
||||
base := make(map[string]any)
|
||||
|
||||
for _, val := range vals {
|
||||
data, err := conversions.To[map[string]any](val)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = mergo.Merge(&base, data)
|
||||
}
|
||||
|
||||
data, _ := conversions.To[T](base)
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type BoolQuery struct {
|
||||
must []Builder
|
||||
mustNot []Builder
|
||||
should []Builder
|
||||
filter []Builder
|
||||
params *BoolQueryParams
|
||||
}
|
||||
|
||||
type BoolQueryParams struct {
|
||||
MinimumShouldMatch int16 `json:"minimum_should_match,omitempty"`
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
Name string `json:"_name,omitempty"`
|
||||
}
|
||||
|
||||
func NewBoolQuery() *BoolQuery {
|
||||
return &BoolQuery{}
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Params(v *BoolQueryParams) *BoolQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Must(v ...Builder) *BoolQuery {
|
||||
q.must = append(q.must, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) MustNot(v ...Builder) *BoolQuery {
|
||||
q.mustNot = append(q.mustNot, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Should(v ...Builder) *BoolQuery {
|
||||
q.should = append(q.should, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Filter(v ...Builder) *BoolQuery {
|
||||
q.filter = append(q.filter, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "must", q.must...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "must_not", q.mustNot...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "should", q.should...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "filter", q.filter...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"bool": base,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *BoolQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestBoolQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewBoolQuery(),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewBoolQuery().Params(&osu.BoolQueryParams{
|
||||
MinimumShouldMatch: 10,
|
||||
Boost: 10,
|
||||
Name: "some-name",
|
||||
}),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"minimum_should_match": 10,
|
||||
"boost": 10,
|
||||
"_name": "some-name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "must",
|
||||
Got: osu.NewBoolQuery().Must(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "must_not",
|
||||
Got: osu.NewBoolQuery().MustNot(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must_not": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "should",
|
||||
Got: osu.NewBoolQuery().Should(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"should": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "filter",
|
||||
Got: osu.NewBoolQuery().Filter(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"filter": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "full",
|
||||
Got: osu.NewBoolQuery().
|
||||
Must(osu.NewTermQuery[string]("name").Value("tom")).
|
||||
MustNot(osu.NewTermQuery[bool]("deleted").Value(true)).
|
||||
Should(osu.NewTermQuery[string]("gender").Value("male")).
|
||||
Filter(osu.NewTermQuery[int]("age").Value(42)),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"must_not": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"deleted": map[string]any{
|
||||
"value": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"should": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"gender": map[string]any{
|
||||
"value": "male",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"filter": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"age": map[string]any{
|
||||
"value": 42,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type MatchPhraseQuery struct {
|
||||
field string
|
||||
query string
|
||||
params *MatchPhraseQueryParams
|
||||
}
|
||||
|
||||
type MatchPhraseQueryParams struct {
|
||||
Analyzer string `json:"analyzer,omitempty"`
|
||||
Slop int `json:"slop,omitempty"`
|
||||
ZeroTermsQuery string `json:"zero_terms_query,omitempty"`
|
||||
}
|
||||
|
||||
func NewMatchPhraseQuery(field string) *MatchPhraseQuery {
|
||||
return &MatchPhraseQuery{field: field}
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) Params(v *MatchPhraseQueryParams) *MatchPhraseQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) Query(v string) *MatchPhraseQuery {
|
||||
q.query = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "query", q.query)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestNewMatchPhraseQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewMatchPhraseQuery("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewMatchPhraseQuery("name").Params(&osu.MatchPhraseQueryParams{
|
||||
Analyzer: "analyzer",
|
||||
Slop: 2,
|
||||
ZeroTermsQuery: "all",
|
||||
}),
|
||||
Want: map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
"name": map[string]any{
|
||||
"analyzer": "analyzer",
|
||||
"slop": 2,
|
||||
"zero_terms_query": "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "query",
|
||||
Got: osu.NewMatchPhraseQuery("name").Query("some match query"),
|
||||
Want: map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
"name": map[string]any{
|
||||
"query": "some match query",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "full",
|
||||
Got: osu.NewMatchPhraseQuery("name").Params(&osu.MatchPhraseQueryParams{
|
||||
Analyzer: "analyzer",
|
||||
Slop: 2,
|
||||
ZeroTermsQuery: "all",
|
||||
}).Query("some match query"),
|
||||
Want: map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
"name": map[string]any{
|
||||
"query": "some match query",
|
||||
"analyzer": "analyzer",
|
||||
"slop": 2,
|
||||
"zero_terms_query": "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type IDsQuery struct {
|
||||
values []string
|
||||
params *IDsQueryParams
|
||||
}
|
||||
|
||||
type IDsQueryParams struct {
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
}
|
||||
|
||||
func NewIDsQuery(v ...string) *IDsQuery {
|
||||
return &IDsQuery{values: slices.Compact(v)}
|
||||
}
|
||||
|
||||
func (q *IDsQuery) Params(v *IDsQueryParams) *IDsQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *IDsQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "values", q.values)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"ids": base,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *IDsQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestIDsQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewIDsQuery(),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "no params",
|
||||
Got: osu.NewIDsQuery("1", "2", "3", "3"),
|
||||
Want: map[string]any{
|
||||
"ids": map[string]any{
|
||||
"values": []string{"1", "2", "3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ids",
|
||||
Got: osu.NewIDsQuery("1", "2", "3", "3").Params(&osu.IDsQueryParams{Boost: 1.0}),
|
||||
Want: map[string]any{
|
||||
"ids": map[string]any{
|
||||
"values": []string{"1", "2", "3"},
|
||||
"boost": 1.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RangeQuery[T time.Time | string] struct {
|
||||
field string
|
||||
gt T
|
||||
gte T
|
||||
lt T
|
||||
lte T
|
||||
params *RangeQueryParams
|
||||
}
|
||||
|
||||
type RangeQueryParams struct {
|
||||
Format string `json:"format,omitempty"`
|
||||
Relation string `json:"relation,omitempty"`
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
TimeZone string `json:"time_zone,omitempty"`
|
||||
}
|
||||
|
||||
func NewRangeQuery[T time.Time | string](field string) *RangeQuery[T] {
|
||||
return &RangeQuery[T]{field: field}
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) Params(v *RangeQueryParams) *RangeQuery[T] {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) Gt(v T) *RangeQuery[T] {
|
||||
q.gt = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) Gte(v T) *RangeQuery[T] {
|
||||
q.gte = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) Lt(v T) *RangeQuery[T] {
|
||||
q.lt = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) Lte(v T) *RangeQuery[T] {
|
||||
q.lte = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) Map() (map[string]any, error) {
|
||||
if !isEmpty(q.gt) && !isEmpty(q.gte) {
|
||||
return nil, errors.New("cannot set both gt and gte in RangeQuery")
|
||||
}
|
||||
|
||||
if !isEmpty(q.lt) && !isEmpty(q.lte) {
|
||||
return nil, errors.New("cannot set both lt and lte in RangeQuery")
|
||||
}
|
||||
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValues(base, map[string]T{
|
||||
"gt": q.gt,
|
||||
"gte": q.gte,
|
||||
"lt": q.lt,
|
||||
"lte": q.lte,
|
||||
})
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"range": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestRangeQuery(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewRangeQuery[string]("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "gt string",
|
||||
Got: osu.NewRangeQuery[string]("created").Gt("2023-01-01T00:00:00Z"),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"gt": "2023-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gt time",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Gt(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"gt": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gte string",
|
||||
Got: osu.NewRangeQuery[string]("created").Gte("2023-01-01T00:00:00Z"),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"gte": "2023-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gte time",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Gte(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"gte": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gt & gte",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Gt(now).Gte(now),
|
||||
Want: nil,
|
||||
Err: errors.New(""),
|
||||
},
|
||||
{
|
||||
Name: "gt string",
|
||||
Got: osu.NewRangeQuery[string]("created").Lt("2023-01-01T00:00:00Z"),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"lt": "2023-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lt time",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Lt(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"lt": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lte string",
|
||||
Got: osu.NewRangeQuery[string]("created").Lte("2023-01-01T00:00:00Z"),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"lte": "2023-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lte time",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Lte(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"lte": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lt & lte",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Lt(now).Lte(now),
|
||||
Want: nil,
|
||||
Err: errors.New(""),
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Params(&osu.RangeQueryParams{
|
||||
Format: "strict_date_optional_time",
|
||||
Relation: "within",
|
||||
Boost: 1.0,
|
||||
TimeZone: "UTC",
|
||||
}).Lte(now).Gte(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"lte": now,
|
||||
"gte": now,
|
||||
"format": "strict_date_optional_time",
|
||||
"relation": "within",
|
||||
"boost": 1.0,
|
||||
"time_zone": "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
got, err := test.Got.MarshalJSON()
|
||||
switch {
|
||||
case test.Err != nil && test.Err.Error() == "": // Expecting any error
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
return
|
||||
case test.Err != nil && test.Err.Error() != "": // Expecting a specific error
|
||||
assert.ErrorIs(t, test.Err, err)
|
||||
assert.Nil(t, got)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type TermQuery[T comparable] struct {
|
||||
field string
|
||||
value T
|
||||
params *TermQueryParams
|
||||
}
|
||||
|
||||
type TermQueryParams struct {
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
CaseInsensitive bool `json:"case_insensitive,omitempty"`
|
||||
Name string `json:"_name,omitempty"`
|
||||
}
|
||||
|
||||
func NewTermQuery[T comparable](field string) *TermQuery[T] {
|
||||
return &TermQuery[T]{field: field}
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) Params(v *TermQueryParams) *TermQuery[T] {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) Value(v T) *TermQuery[T] {
|
||||
q.value = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "value", q.value)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"term": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestTermQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewTermQuery[string]("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "no params",
|
||||
Got: osu.NewTermQuery[bool]("deleted").Value(false),
|
||||
Want: map[string]any{
|
||||
"term": map[string]any{
|
||||
"deleted": map[string]any{
|
||||
"value": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewTermQuery[bool]("deleted").Params(&osu.TermQueryParams{
|
||||
Boost: 1.0,
|
||||
CaseInsensitive: true,
|
||||
Name: "is-deleted",
|
||||
}).Value(true),
|
||||
Want: map[string]any{
|
||||
"term": map[string]any{
|
||||
"deleted": map[string]any{
|
||||
"value": true,
|
||||
"boost": 1.0,
|
||||
"case_insensitive": true,
|
||||
"_name": "is-deleted",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type WildcardQuery struct {
|
||||
field string
|
||||
value string
|
||||
params *WildcardQueryParams
|
||||
}
|
||||
|
||||
type WildcardQueryParams struct {
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
CaseInsensitive bool `json:"case_insensitive,omitempty"`
|
||||
Rewrite string `json:"rewrite,omitempty"`
|
||||
}
|
||||
|
||||
func NewWildcardQuery(field string) *WildcardQuery {
|
||||
return &WildcardQuery{field: field}
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) Params(v *WildcardQueryParams) *WildcardQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) Value(v string) *WildcardQuery {
|
||||
q.value = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "value", q.value)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"wildcard": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestWildcardQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewWildcardQuery("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "wildcard",
|
||||
Got: osu.NewWildcardQuery("name").Params(&osu.WildcardQueryParams{
|
||||
Boost: 1.0,
|
||||
CaseInsensitive: true,
|
||||
Rewrite: "top_terms_blended_freqs_N",
|
||||
}).Value("opencl*"),
|
||||
Want: map[string]any{
|
||||
"wildcard": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "opencl*",
|
||||
"boost": 1.0,
|
||||
"case_insensitive": true,
|
||||
"rewrite": "top_terms_blended_freqs_N",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
)
|
||||
|
||||
type QueryReqBody[P any] struct {
|
||||
query Builder
|
||||
params P
|
||||
}
|
||||
|
||||
func NewQueryReqBody[P any](q Builder, p ...P) *QueryReqBody[P] {
|
||||
return &QueryReqBody[P]{query: q, params: merge(p...)}
|
||||
}
|
||||
|
||||
func (q QueryReqBody[O]) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilder(base, "query", q.query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (q QueryReqBody[O]) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(data)
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
type BodyParamHighlight struct {
|
||||
HighlightOptions
|
||||
Fields map[string]HighlightOptions `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type HighlightType string
|
||||
|
||||
const (
|
||||
HighlightTypeUnified HighlightType = "unified"
|
||||
HighlightTypeFvh HighlightType = "fvh"
|
||||
HighlightTypePlain HighlightType = "plain"
|
||||
HighlightTypeSemantic HighlightType = "semantic"
|
||||
)
|
||||
|
||||
type HighlightOptions struct {
|
||||
Type HighlightType `json:"type,omitempty"`
|
||||
FragmentSize int `json:"fragment_size,omitempty"`
|
||||
NumberOfFragments int `json:"number_of_fragments,omitempty"`
|
||||
FragmentOffset int `json:"fragment_offset,omitempty"`
|
||||
BoundaryChars string `json:"boundary_chars,omitempty"`
|
||||
BoundaryMaxScan int `json:"boundary_max_scan,omitempty"`
|
||||
BoundaryScanner string `json:"boundary_scanner,omitempty"`
|
||||
BoundaryScannerLocale string `json:"boundary_scanner_locale,omitempty"`
|
||||
Encoder string `json:"encoder,omitempty"`
|
||||
ForceSource bool `json:"force_source,omitempty"`
|
||||
Fragmenter string `json:"fragmenter,omitempty"`
|
||||
HighlightQuery Builder `json:"highlight_query,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
NoMatchSize int `json:"no_match_size,omitempty"`
|
||||
RequireFieldMatch bool `json:"require_field_match,omitempty"`
|
||||
MatchedFields []string `json:"matched_fields,omitempty"`
|
||||
PhraseLimit int `json:"phrase_limit,omitempty"`
|
||||
PreTags []string `json:"pre_tags,omitempty"`
|
||||
PostTags []string `json:"post_tags,omitempty"`
|
||||
}
|
||||
|
||||
type BodyParamScript struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyParams) (*opensearchgoAPI.SearchReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody(q, p...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
type SearchBodyParams struct {
|
||||
Highlight *BodyParamHighlight `json:"highlight,omitempty"`
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildDocumentDeleteByQueryReq(req opensearchgoAPI.DocumentDeleteByQueryReq, q Builder) (opensearchgoAPI.DocumentDeleteByQueryReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody[any](q))
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildUpdateByQueryReq(req opensearchgoAPI.UpdateByQueryReq, q Builder, o ...UpdateByQueryBodyParams) (opensearchgoAPI.UpdateByQueryReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody(q, o...))
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
type UpdateByQueryBodyParams struct {
|
||||
Script *BodyParamScript `json:"script,omitempty"`
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildIndicesCountReq(req *opensearchgoAPI.IndicesCountReq, q Builder) (*opensearchgoAPI.IndicesCountReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody[any](q))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
opensearchtest "github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestRequestBody(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "simple",
|
||||
Got: osu.NewQueryReqBody[any](osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"query": map[string]any{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchReq(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[io.Reader, map[string]any]{
|
||||
{
|
||||
Name: "highlight",
|
||||
Got: func() io.Reader {
|
||||
req, _ := osu.BuildSearchReq(
|
||||
&opensearchgoAPI.SearchReq{},
|
||||
osu.NewTermQuery[string]("content").Value("content"),
|
||||
osu.SearchBodyParams{
|
||||
Highlight: &osu.BodyParamHighlight{
|
||||
HighlightOptions: osu.HighlightOptions{
|
||||
PreTags: []string{"<b>"},
|
||||
PostTags: []string{"</b>"},
|
||||
},
|
||||
Fields: map[string]osu.HighlightOptions{
|
||||
"content": {
|
||||
PreTags: []string{"<strong>"},
|
||||
PostTags: []string{"</strong>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return req.Body
|
||||
}(),
|
||||
Want: map[string]any{
|
||||
"query": map[string]any{
|
||||
"term": map[string]any{
|
||||
"content": map[string]any{
|
||||
"value": "content",
|
||||
},
|
||||
},
|
||||
},
|
||||
"highlight": map[string]any{
|
||||
"pre_tags": []string{"<b>"},
|
||||
"post_tags": []string{"</b>"},
|
||||
"fields": map[string]any{
|
||||
"content": map[string]any{
|
||||
"pre_tags": []string{"<strong>"},
|
||||
"post_tags": []string{"</strong>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
body, err := io.ReadAll(test.Got)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), string(body))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package opensearchtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
)
|
||||
|
||||
var TimeMustParse = func(t *testing.T, ts string) time.Time {
|
||||
tp, err := time.Parse(time.RFC3339Nano, ts)
|
||||
require.NoError(t, err, "failed to parse time %s", ts)
|
||||
|
||||
return tp
|
||||
}
|
||||
|
||||
func JSONMustMarshal(t *testing.T, data any) string {
|
||||
jsonData, err := json.Marshal(data)
|
||||
require.NoError(t, err, "failed to marshal data to JSON")
|
||||
return string(jsonData)
|
||||
}
|
||||
|
||||
func SearchHitsMustBeConverted[T any](t *testing.T, hits []opensearchgoAPI.SearchHit) []T {
|
||||
ts := make([]T, len(hits))
|
||||
for i, hit := range hits {
|
||||
resource, err := conversions.To[T](hit.Source)
|
||||
require.NoError(t, err)
|
||||
ts[i] = resource
|
||||
}
|
||||
|
||||
return ts
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package opensearchtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
)
|
||||
|
||||
type TestClient struct {
|
||||
c *opensearchgoAPI.Client
|
||||
Require *testRequireClient
|
||||
}
|
||||
|
||||
func NewDefaultTestClient(t *testing.T, cfg config.EngineOpenSearchClient) *TestClient {
|
||||
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{
|
||||
Client: opensearchgo.Config{
|
||||
Addresses: cfg.Addresses,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "failed to create OpenSearch client")
|
||||
|
||||
return NewTestClient(t, client)
|
||||
}
|
||||
|
||||
func NewTestClient(t *testing.T, client *opensearchgoAPI.Client) *TestClient {
|
||||
tc := &TestClient{c: client}
|
||||
trc := &testRequireClient{tc: tc, t: t}
|
||||
tc.Require = trc
|
||||
|
||||
return tc
|
||||
}
|
||||
|
||||
func (tc *TestClient) Client() *opensearchgoAPI.Client {
|
||||
return tc.c
|
||||
}
|
||||
|
||||
func (tc *TestClient) IndicesReset(ctx context.Context, indices []string) error {
|
||||
indicesToDelete := make([]string, 0, len(indices))
|
||||
for _, index := range indices {
|
||||
exist, err := tc.IndicesExists(ctx, []string{index})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if index %s exists: %w", index, err)
|
||||
}
|
||||
|
||||
if !exist {
|
||||
continue
|
||||
}
|
||||
|
||||
indicesToDelete = append(indicesToDelete, index)
|
||||
}
|
||||
|
||||
if len(indicesToDelete) == 0 {
|
||||
// If no indices to delete, return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return tc.IndicesDelete(ctx, indicesToDelete)
|
||||
}
|
||||
|
||||
func (tc *TestClient) IndicesExists(ctx context.Context, indices []string) (bool, error) {
|
||||
if err := tc.IndicesRefresh(ctx, indices, []int{404}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := tc.c.Indices.Exists(ctx, opensearchgoAPI.IndicesExistsReq{
|
||||
Indices: indices,
|
||||
})
|
||||
switch {
|
||||
case resp != nil && resp.StatusCode == 404:
|
||||
return false, nil
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("failed to check if indices exist: %w", err)
|
||||
case resp != nil && resp.IsError():
|
||||
return false, fmt.Errorf("failed to check if indices exist: %s", resp.String())
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TestClient) IndicesRefresh(ctx context.Context, indices []string, allow []int) error {
|
||||
resp, err := tc.c.Indices.Refresh(ctx, &opensearchgoAPI.IndicesRefreshReq{
|
||||
Indices: indices,
|
||||
})
|
||||
|
||||
isAllowed := resp != nil
|
||||
isAllowed = isAllowed && resp.Inspect().Response != nil
|
||||
isAllowed = isAllowed && slices.Contains(allow, resp.Inspect().Response.StatusCode)
|
||||
|
||||
if err != nil && !isAllowed {
|
||||
return fmt.Errorf("failed to refresh indices %v: %w", indices, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *TestClient) IndicesDelete(ctx context.Context, indices []string) error {
|
||||
if err := tc.IndicesRefresh(ctx, indices, []int{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := tc.c.Indices.Delete(ctx, opensearchgoAPI.IndicesDeleteReq{
|
||||
Indices: indices,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to delete indices: %w", err)
|
||||
case !resp.Acknowledged:
|
||||
return errors.New("indices deletion not acknowledged")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TestClient) IndicesCreate(ctx context.Context, index string, body io.Reader) error {
|
||||
resp, err := tc.c.Indices.Create(ctx, opensearchgoAPI.IndicesCreateReq{
|
||||
Index: index,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to create index %s: %w", index, err)
|
||||
case !resp.Acknowledged:
|
||||
return fmt.Errorf("index creation not acknowledged for %s", index)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TestClient) IndicesCount(ctx context.Context, indices []string, body io.Reader) (int, error) {
|
||||
if err := tc.IndicesRefresh(ctx, indices, []int{404}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp, err := tc.c.Indices.Count(ctx, &opensearchgoAPI.IndicesCountReq{
|
||||
Indices: indices,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return 0, fmt.Errorf("failed to count documents in indices: %w", err)
|
||||
default:
|
||||
return resp.Count, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TestClient) DocumentCreate(ctx context.Context, index, id string, body io.Reader) error {
|
||||
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tc.c.Document.Create(ctx, opensearchgoAPI.DocumentCreateReq{
|
||||
Index: index,
|
||||
DocumentID: id,
|
||||
Body: body,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to create document in index %s: %w", index, err)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TestClient) Update(ctx context.Context, index, id string, body io.Reader) error {
|
||||
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tc.c.Update(ctx, opensearchgoAPI.UpdateReq{
|
||||
Index: index,
|
||||
DocumentID: id,
|
||||
Body: body,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to update document in index %s: %w", index, err)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TestClient) Search(ctx context.Context, index string, body io.Reader) (opensearchgoAPI.SearchHits, error) {
|
||||
if err := tc.IndicesRefresh(ctx, []string{index}, []int{404}); err != nil {
|
||||
return opensearchgoAPI.SearchHits{}, err
|
||||
}
|
||||
|
||||
resp, err := tc.c.Search(ctx, &opensearchgoAPI.SearchReq{
|
||||
Indices: []string{index},
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return opensearchgoAPI.SearchHits{}, fmt.Errorf("failed to search in index %s: %w", index, err)
|
||||
}
|
||||
|
||||
return resp.Hits, nil
|
||||
}
|
||||
|
||||
type testRequireClient struct {
|
||||
tc *TestClient
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) IndicesReset(indices []string) {
|
||||
require.NoError(trc.t, trc.tc.IndicesReset(trc.t.Context(), indices))
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) IndicesRefresh(indices []string, ignore []int) {
|
||||
require.NoError(trc.t, trc.tc.IndicesRefresh(trc.t.Context(), indices, ignore))
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) IndicesCreate(index string, body io.Reader) {
|
||||
require.NoError(trc.t, trc.tc.IndicesCreate(trc.t.Context(), index, body))
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) IndicesDelete(indices []string) {
|
||||
require.NoError(trc.t, trc.tc.IndicesDelete(trc.t.Context(), indices))
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) IndicesCount(indices []string, body io.Reader, expected int) {
|
||||
count, err := trc.tc.IndicesCount(trc.t.Context(), indices, body)
|
||||
|
||||
switch {
|
||||
case expected <= 0:
|
||||
require.True(trc.t, count <= 0, "expected indices to have no documents, but got a count of %d", count)
|
||||
default:
|
||||
require.Equal(trc.t, expected, count, "expected indices to have %d documents, but got %d", expected, count)
|
||||
require.NoError(trc.t, err, "expected indices to have documents, but got an error")
|
||||
}
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) DocumentCreate(index, id string, body io.Reader) {
|
||||
require.NoError(trc.t, trc.tc.DocumentCreate(trc.t.Context(), index, id, body))
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) Update(index, id string, body io.Reader) {
|
||||
require.NoError(trc.t, trc.tc.Update(trc.t.Context(), index, id, body))
|
||||
}
|
||||
|
||||
func (trc *testRequireClient) Search(index string, body io.Reader) opensearchgoAPI.SearchHits {
|
||||
hits, err := trc.tc.Search(trc.t.Context(), index, body)
|
||||
require.NoError(trc.t, err)
|
||||
return hits
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package opensearchtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/opensearch"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/config/defaults"
|
||||
"github.com/qsfera/server/services/search/pkg/config/parser"
|
||||
)
|
||||
|
||||
const (
|
||||
openSearchImage = "opensearchproject/opensearch:2"
|
||||
)
|
||||
|
||||
func SetupTests(ctx context.Context) (*config.Config, func(), error) {
|
||||
cfg, err := setupDefaultConfig()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to setup default configuration: %w", err)
|
||||
}
|
||||
|
||||
cleanupOpenSearch, err := setupOpenSearchTestContainer(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to setup OpenSearch test container: %w", err)
|
||||
}
|
||||
|
||||
return cfg, cleanupOpenSearch, nil
|
||||
}
|
||||
|
||||
func setupDefaultConfig() (*config.Config, error) {
|
||||
cfg := defaults.DefaultConfig()
|
||||
defaults.EnsureDefaults(cfg)
|
||||
|
||||
{ // parser.Validate requires these fields to be set even if they are not used in these tests.
|
||||
cfg.TokenManager.JWTSecret = "any-jwt"
|
||||
cfg.ServiceAccount.ServiceAccountID = "any-service-account-id"
|
||||
cfg.ServiceAccount.ServiceAccountSecret = "any-service-account-secret"
|
||||
}
|
||||
|
||||
if err := parser.ParseConfig(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse default configuration: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func(), error) {
|
||||
usesExternalOpensearch := slices.Contains([]bool{
|
||||
os.Getenv("CI") == "woodpecker",
|
||||
os.Getenv("CI_SYSTEM_NAME") == "woodpecker",
|
||||
os.Getenv("USE_TESTCONTAINERS") == "false",
|
||||
}, true)
|
||||
if usesExternalOpensearch {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
containerName := fmt.Sprintf("qsfera/test/%s", openSearchImage)
|
||||
containerName = strings.Replace(containerName, "/", "__", -1)
|
||||
containerName = strings.Replace(containerName, ":", "_", -1)
|
||||
container, err := opensearch.Run(
|
||||
ctx,
|
||||
openSearchImage,
|
||||
opensearch.WithUsername(cfg.Engine.OpenSearch.Client.Username),
|
||||
opensearch.WithPassword(cfg.Engine.OpenSearch.Client.Password),
|
||||
testcontainers.WithName(containerName),
|
||||
testcontainers.WithReuseByName(containerName),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("ML configuration initialized successfully").
|
||||
WithStartupTimeout(5*time.Second),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start OpenSearch container: %w", err)
|
||||
}
|
||||
|
||||
address, err := container.Address(ctx)
|
||||
if err != nil {
|
||||
_ = container.Terminate(ctx) // attempt to clean up the container
|
||||
return nil, fmt.Errorf("failed to get OpenSearch container address: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the address is set in the default configuration.
|
||||
cfg.Engine.OpenSearch.Client.Addresses = []string{address}
|
||||
|
||||
return func() {
|
||||
err := container.Terminate(ctx)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "failed to terminate OpenSearch container: %v\n", err)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package opensearchtest
|
||||
|
||||
type TableTest[G any, W any] struct {
|
||||
Name string
|
||||
Got G
|
||||
Want W
|
||||
Err error
|
||||
Skip bool
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package opensearchtest
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
//go:embed testdata/*.json
|
||||
var testdata embed.FS
|
||||
|
||||
var Testdata = struct {
|
||||
Resources resourceTestdata
|
||||
}{
|
||||
Resources: resourceTestdata{
|
||||
Root: fromTestData[search.Resource]("resource_root.json"),
|
||||
Folder: fromTestData[search.Resource]("resource_folder.json"),
|
||||
File: fromTestData[search.Resource]("resource_file.json"),
|
||||
},
|
||||
}
|
||||
|
||||
type resourceTestdata struct {
|
||||
Root search.Resource
|
||||
File search.Resource
|
||||
Folder search.Resource
|
||||
}
|
||||
|
||||
func fromTestData[D any](name string) D {
|
||||
name = path.Join("./testdata", name)
|
||||
data, err := testdata.ReadFile(name)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to read testdata file %s: %v", name, err))
|
||||
}
|
||||
|
||||
var d D
|
||||
if json.Unmarshal(data, &d) != nil {
|
||||
panic(fmt.Sprintf("failed to unmarshal testdata %s: %v", name, err))
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"ID" : "1$1!3",
|
||||
"RootID" : "1$1!1",
|
||||
"ParentID" : "1$1!2",
|
||||
"Path" : "./parent d!r/child.jpg",
|
||||
"Type" : 1,
|
||||
"Title" : "dumme title",
|
||||
"Name" : "dummy name",
|
||||
"Content" : "dummy content",
|
||||
"Size" : 42,
|
||||
"Mtime" : "2025-07-24 15:15:01.324093 +0200 CEST m=+0.000056251",
|
||||
"MimeType" : "image/jpeg",
|
||||
"Tags" : [ "dummy" ],
|
||||
"Deleted" : false,
|
||||
"Hidden" : false,
|
||||
"audio" : {
|
||||
"album" : "Some Album",
|
||||
"albumArtist" : "Some AlbumArtist",
|
||||
"artist" : "Some Artist",
|
||||
"bitrate" : 192,
|
||||
"composers" : "Some Composers",
|
||||
"copyright" : "",
|
||||
"disc" : 2,
|
||||
"discCount" : 5,
|
||||
"duration" : 225000,
|
||||
"genre" : "Some Genre",
|
||||
"hasDrm" : false,
|
||||
"isVariableBitrate" : true,
|
||||
"title" : "Some Title",
|
||||
"track" : 34,
|
||||
"trackCount" : 99,
|
||||
"year" : 2004
|
||||
},
|
||||
"image" : {
|
||||
"height" : 100,
|
||||
"width" : 100
|
||||
},
|
||||
"location" : {
|
||||
"altitude" : 1047.7,
|
||||
"latitude" : 49.48675890884328,
|
||||
"longitude" : 11.103870357204285
|
||||
},
|
||||
"photo" : {
|
||||
"cameraMake" : "CameraMake",
|
||||
"cameraModel" : "CameraMake",
|
||||
"exposureDenominator" : 1,
|
||||
"exposureNumerator" : 1,
|
||||
"fNumber" : 1,
|
||||
"focalLength" : 1,
|
||||
"iso" : 1,
|
||||
"orientation" : 1,
|
||||
"takenDateTime" : "2018-01-01T12:34:56Z"
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ID" : "1$1!2",
|
||||
"RootID" : "1$1!1",
|
||||
"ParentID" : "1$1!1",
|
||||
"Path" : "./parent d!r",
|
||||
"Type" : 2
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ID" : "1$1!1",
|
||||
"RootID" : "1$1!1",
|
||||
"Path" : "."
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
)
|
||||
|
||||
func searchResourceByID(ctx context.Context, client *opensearchgoAPI.Client, index string, id string) (search.Resource, error) {
|
||||
req, err := osu.BuildSearchReq(
|
||||
&opensearchgoAPI.SearchReq{
|
||||
Indices: []string{index},
|
||||
},
|
||||
osu.NewIDsQuery(id),
|
||||
)
|
||||
if err != nil {
|
||||
return search.Resource{}, fmt.Errorf("failed to build search request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Search(ctx, req)
|
||||
switch {
|
||||
case err != nil:
|
||||
return search.Resource{}, fmt.Errorf("failed to search for resource: %w", err)
|
||||
case resp.Hits.Total.Value == 0 || len(resp.Hits.Hits) == 0:
|
||||
return search.Resource{}, fmt.Errorf("document with id %s not found", id)
|
||||
}
|
||||
|
||||
resource, err := conversions.To[search.Resource](resp.Hits.Hits[0].Source)
|
||||
if err != nil {
|
||||
return search.Resource{}, fmt.Errorf("failed to convert hit source: %w", err)
|
||||
}
|
||||
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
func updateSelfAndDescendants(ctx context.Context, client *opensearchgoAPI.Client, index string, id string, scriptProvider func(search.Resource) *osu.BodyParamScript) error {
|
||||
if scriptProvider == nil {
|
||||
return fmt.Errorf("script cannot be nil")
|
||||
}
|
||||
|
||||
resource, err := searchResourceByID(context.Background(), client, index, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get resource: %w", err)
|
||||
}
|
||||
|
||||
req, err := osu.BuildUpdateByQueryReq(
|
||||
opensearchgoAPI.UpdateByQueryReq{
|
||||
Indices: []string{index},
|
||||
Params: opensearchgoAPI.UpdateByQueryParams{
|
||||
WaitForCompletion: conversions.ToPointer(true),
|
||||
},
|
||||
},
|
||||
osu.NewTermQuery[string]("Path").Value(resource.Path),
|
||||
osu.UpdateByQueryBodyParams{
|
||||
Script: scriptProvider(resource),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build update by query request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.UpdateByQuery(ctx, req)
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to update by query: %w", err)
|
||||
case len(resp.Failures) != 0:
|
||||
return fmt.Errorf("failed to update by query, failures: %v", resp.Failures)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package opensearch_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
opensearchtest "github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
var defaultConfig *config.Config
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
cfg, done, err := opensearchtest.SetupTests(context.Background())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "Failed to setup tests:", err)
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
defaultConfig = cfg
|
||||
code := m.Run()
|
||||
done()
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package bleve provides the ability to work with bleve queries.
|
||||
package bleve
|
||||
|
||||
import (
|
||||
bQuery "github.com/blevesearch/bleve/v2/search/query"
|
||||
|
||||
"github.com/qsfera/server/pkg/kql"
|
||||
"github.com/qsfera/server/services/search/pkg/query"
|
||||
)
|
||||
|
||||
// Creator is combines a Builder and a Compiler which is used to Create the query.
|
||||
type Creator[T any] struct {
|
||||
builder query.Builder
|
||||
compiler query.Compiler[T]
|
||||
}
|
||||
|
||||
// Create implements the Creator interface
|
||||
func (c Creator[T]) Create(qs string) (T, error) {
|
||||
var t T
|
||||
builderAst, err := c.builder.Build(qs)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
t, err = c.compiler.Compile(builderAst)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DefaultCreator exposes a kql to bleve query creator.
|
||||
var DefaultCreator = Creator[bQuery.Query]{kql.Builder{}, Compiler{}}
|
||||
@@ -0,0 +1,365 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
|
||||
"github.com/qsfera/server/pkg/ast"
|
||||
"github.com/qsfera/server/pkg/kql"
|
||||
)
|
||||
|
||||
// lowercaseFields lists the bleve fields whose index mapping uses a
|
||||
// lowercasing analyzer. Values bound to these fields are pre-lowercased
|
||||
// so query-side matching stays consistent with the index.
|
||||
// Keep in sync with services/search/pkg/bleve/index.go NewMapping.
|
||||
var lowercaseFields = map[string]struct{}{
|
||||
"Name": {},
|
||||
"Tags": {},
|
||||
"Favorites": {},
|
||||
"Content": {},
|
||||
}
|
||||
|
||||
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",
|
||||
"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
|
||||
var bleveEscaper = strings.NewReplacer(
|
||||
`+`, `\+`,
|
||||
`-`, `\-`,
|
||||
`=`, `\=`,
|
||||
`&`, `\&`,
|
||||
`|`, `\|`,
|
||||
`>`, `\>`,
|
||||
`<`, `\<`,
|
||||
`!`, `\!`,
|
||||
`(`, `\(`,
|
||||
`)`, `\)`,
|
||||
`{`, `\{`,
|
||||
`}`, `\}`,
|
||||
`{`, `\}`,
|
||||
`[`, `\[`,
|
||||
`]`, `\]`,
|
||||
`^`, `\^`,
|
||||
`"`, `\"`,
|
||||
`~`, `\~`,
|
||||
`:`, `\:`,
|
||||
`\`, `\\`,
|
||||
`/`, `\/`,
|
||||
` `, `\ `,
|
||||
)
|
||||
|
||||
// Compiler represents a KQL query search string to the bleve query formatter.
|
||||
type Compiler struct{}
|
||||
|
||||
// Compile implements the query formatter which converts the KQL query search string to the bleve query.
|
||||
func (c Compiler) Compile(givenAst *ast.Ast) (bleveQuery.Query, error) {
|
||||
q, err := compile(givenAst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func compile(a *ast.Ast) (bleveQuery.Query, error) {
|
||||
q, _, err := walk(0, a.Nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch q.(type) {
|
||||
case *bleveQuery.ConjunctionQuery, *bleveQuery.DisjunctionQuery:
|
||||
return q, nil
|
||||
}
|
||||
return bleve.NewConjunctionQuery(q), nil
|
||||
}
|
||||
|
||||
func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
||||
var prev, next bleveQuery.Query
|
||||
var operator *ast.OperatorNode
|
||||
var isGroup bool
|
||||
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)
|
||||
}
|
||||
|
||||
var q bleveQuery.Query
|
||||
var group bool
|
||||
switch k {
|
||||
case "MimeType":
|
||||
q, group = mimeType(k, v)
|
||||
if prev == nil {
|
||||
isGroup = group
|
||||
}
|
||||
default:
|
||||
q = bleveQuery.NewQueryStringQuery(k + ":" + v)
|
||||
}
|
||||
|
||||
if prev == nil {
|
||||
prev = q
|
||||
} else {
|
||||
next = q
|
||||
}
|
||||
case *ast.DateTimeNode:
|
||||
q := &bleveQuery.DateRangeQuery{
|
||||
Start: bleveQuery.BleveQueryTime{},
|
||||
End: bleveQuery.BleveQueryTime{},
|
||||
InclusiveStart: nil,
|
||||
InclusiveEnd: nil,
|
||||
FieldVal: getField(n.Key),
|
||||
}
|
||||
|
||||
if n.Operator == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch n.Operator.Value {
|
||||
case ">":
|
||||
q.Start.Time = n.Value
|
||||
q.InclusiveStart = &[]bool{false}[0]
|
||||
case ">=":
|
||||
q.Start.Time = n.Value
|
||||
q.InclusiveStart = &[]bool{true}[0]
|
||||
case "<":
|
||||
q.End.Time = n.Value
|
||||
q.InclusiveEnd = &[]bool{false}[0]
|
||||
case "<=":
|
||||
q.End.Time = n.Value
|
||||
q.InclusiveEnd = &[]bool{true}[0]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if prev == nil {
|
||||
prev = q
|
||||
} else {
|
||||
next = q
|
||||
}
|
||||
case *ast.BooleanNode:
|
||||
q := bleveQuery.NewQueryStringQuery(getField(n.Key) + fmt.Sprintf(":%v", n.Value))
|
||||
if prev == nil {
|
||||
prev = q
|
||||
} else {
|
||||
next = q
|
||||
}
|
||||
case *ast.GroupNode:
|
||||
if n.Key != "" {
|
||||
n = normalizeGroupingProperty(n)
|
||||
}
|
||||
q, _, err := walk(0, n.Nodes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if prev == nil {
|
||||
prev = q
|
||||
isGroup = true
|
||||
} else {
|
||||
next = q
|
||||
}
|
||||
case *ast.OperatorNode:
|
||||
if n.Value == kql.BoolAND || n.Value == kql.BoolOR {
|
||||
operator = n
|
||||
} else if n.Value == kql.BoolNOT {
|
||||
var err error
|
||||
next, offset, err = nextNode(i+1, nodes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := bleve.NewBooleanQuery()
|
||||
q.AddMustNot(next)
|
||||
if prev == nil {
|
||||
// unary in the beginning
|
||||
prev = q
|
||||
} else {
|
||||
next = q
|
||||
}
|
||||
}
|
||||
}
|
||||
if prev != nil && next != nil && operator != nil {
|
||||
prev = mapBinary(operator, prev, next, isGroup)
|
||||
isGroup = false
|
||||
operator = nil
|
||||
next = nil
|
||||
}
|
||||
if i < offset {
|
||||
i = offset
|
||||
}
|
||||
}
|
||||
if prev == nil {
|
||||
return nil, 0, fmt.Errorf("can not compile the query")
|
||||
}
|
||||
return prev, offset, nil
|
||||
}
|
||||
|
||||
func nextNode(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
||||
if n, ok := nodes[offset].(*ast.GroupNode); ok {
|
||||
gq, _, err := walk(0, n.Nodes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return gq, offset + 1, nil
|
||||
}
|
||||
if n, ok := nodes[offset].(*ast.OperatorNode); ok {
|
||||
if n.Value == kql.BoolNOT {
|
||||
return walk(offset, nodes)
|
||||
}
|
||||
}
|
||||
one := nodes[:offset+1]
|
||||
return walk(offset, one)
|
||||
}
|
||||
|
||||
func mapBinary(operator *ast.OperatorNode, ln, rn bleveQuery.Query, leftIsGroup bool) bleveQuery.Query {
|
||||
if operator.Value == kql.BoolOR {
|
||||
right, ok := rn.(*bleveQuery.DisjunctionQuery)
|
||||
switch left := ln.(type) {
|
||||
case *bleveQuery.DisjunctionQuery:
|
||||
if ok {
|
||||
left.AddQuery(right.Disjuncts...)
|
||||
} else {
|
||||
left.AddQuery(rn)
|
||||
}
|
||||
return left
|
||||
case *bleveQuery.ConjunctionQuery:
|
||||
return bleveQuery.NewDisjunctionQuery([]bleveQuery.Query{ln, rn})
|
||||
default:
|
||||
if ok {
|
||||
left := bleveQuery.NewDisjunctionQuery([]bleveQuery.Query{ln})
|
||||
left.AddQuery(right.Disjuncts...)
|
||||
return left
|
||||
}
|
||||
return bleveQuery.NewDisjunctionQuery([]bleveQuery.Query{ln, rn})
|
||||
}
|
||||
}
|
||||
if operator.Value == kql.BoolAND {
|
||||
switch left := ln.(type) {
|
||||
case *bleveQuery.ConjunctionQuery:
|
||||
left.AddQuery(rn)
|
||||
return left
|
||||
case *bleveQuery.DisjunctionQuery:
|
||||
if !leftIsGroup {
|
||||
last := left.Disjuncts[len(left.Disjuncts)-1]
|
||||
rn = bleveQuery.NewConjunctionQuery([]bleveQuery.Query{
|
||||
last,
|
||||
rn,
|
||||
})
|
||||
dj := bleveQuery.NewDisjunctionQuery(left.Disjuncts[:len(left.Disjuncts)-1])
|
||||
dj.AddQuery(rn)
|
||||
return dj
|
||||
}
|
||||
}
|
||||
}
|
||||
return bleveQuery.NewConjunctionQuery([]bleveQuery.Query{
|
||||
ln,
|
||||
rn,
|
||||
})
|
||||
}
|
||||
|
||||
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 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.oasis.opendocument.spreadsheet",
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
"github.com/qsfera/server/pkg/ast"
|
||||
tAssert "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var timeMustParse = func(t *testing.T, ts string) time.Time {
|
||||
tp, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil {
|
||||
t.Fatalf("time.Parse(...) error = %v", err)
|
||||
}
|
||||
|
||||
return tp
|
||||
}
|
||||
|
||||
func Test_compile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args *ast.Ast
|
||||
want query.Query
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: `federated`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "federated"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:federated`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `"John Smith"`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "John Smith"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `"John Smith" Jane`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "name", Value: "John Smith"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "name", Value: "Jane"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith`),
|
||||
query.NewQueryStringQuery(`Name:jane`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `tag:bestseller tag:book`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "tag", Value: "bestseller"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "tag", Value: "book"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `name:"moby di*" OR tag:bestseller AND tag:book`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "name", Value: "moby di*"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "tag", Value: "bestseller"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "tag", Value: "book"},
|
||||
},
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:moby\ di*`),
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
}),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `a AND b OR c`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "a"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Value: "b"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Value: "c"},
|
||||
},
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:a`),
|
||||
query.NewQueryStringQuery(`Name:b`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:c`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `a OR b AND c`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "a"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Value: "b"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Value: "c"},
|
||||
},
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:a`),
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:b`),
|
||||
query.NewQueryStringQuery(`Name:c`),
|
||||
}),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `(a OR b OR c) AND d`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "a"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Value: "b"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Value: "c"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Value: "d"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:a`),
|
||||
query.NewQueryStringQuery(`Name:b`),
|
||||
query.NewQueryStringQuery(`Name:c`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:d`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `(name:"moby di*" OR tag:bestseller) AND tag:book`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "name", Value: "moby di*"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "tag", Value: "bestseller"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "tag", Value: "book"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:moby\ di*`),
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `(name:"moby di*" OR tag:bestseller) AND tag:book AND NOT tag:read`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "name", Value: "moby di*"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "tag", Value: "bestseller"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "tag", Value: "book"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "tag", Value: "read"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:moby\ di*`),
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:read`)}),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `author:("John Smith" Jane)`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{
|
||||
Key: "author",
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "John Smith"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Value: "Jane"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`author:John\ Smith`),
|
||||
query.NewQueryStringQuery(`author:Jane`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `author:("John Smith" Jane) AND tag:bestseller`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{
|
||||
Key: "author",
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "John Smith"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Value: "Jane"},
|
||||
},
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "tag", Value: "bestseller"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`author:John\ Smith`),
|
||||
query.NewQueryStringQuery(`author:Jane`),
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `id:b27d3bf1-b254-459f-92e8-bdba668d6d3f$d0648459-25fb-4ed8-8684-bc62c7dca29c!d0648459-25fb-4ed8-8684-bc62c7dca29c mtime>=2023-09-05T12:40:59.14741+02:00`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{
|
||||
Key: "id",
|
||||
Value: "b27d3bf1-b254-459f-92e8-bdba668d6d3f$d0648459-25fb-4ed8-8684-bc62c7dca29c!d0648459-25fb-4ed8-8684-bc62c7dca29c",
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{
|
||||
Key: "Mtime",
|
||||
Operator: &ast.OperatorNode{Value: ">="},
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`ID:b27d3bf1-b254-459f-92e8-bdba668d6d3f$d0648459-25fb-4ed8-8684-bc62c7dca29c!d0648459-25fb-4ed8-8684-bc62c7dca29c`),
|
||||
func() query.Query {
|
||||
q := query.NewDateRangeInclusiveQuery(timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), time.Time{}, &[]bool{true}[0], nil)
|
||||
q.FieldVal = "Mtime"
|
||||
return q
|
||||
}(),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `StringNode value lowercase`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "John Smith"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "Hidden", Value: "T"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "hidden", Value: "T"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith`),
|
||||
query.NewQueryStringQuery(`Hidden:T`),
|
||||
query.NewQueryStringQuery(`Hidden:T`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `NOT tag:physik`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "tag", Value: "physik"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:physik`)}),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `ast.DateTimeNode`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.DateTimeNode{
|
||||
Key: "mtime",
|
||||
// "=" is not supported by bleve, ignore
|
||||
Operator: &ast.OperatorNode{Value: "="},
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{
|
||||
Key: "mtime",
|
||||
// ":" is not supported by bleve, ignore
|
||||
Operator: &ast.OperatorNode{Value: ":"},
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{
|
||||
Key: "mtime",
|
||||
// no operator, skip
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{
|
||||
Key: "mtime",
|
||||
Operator: &ast.OperatorNode{Value: ">"},
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{
|
||||
Key: "mtime",
|
||||
Operator: &ast.OperatorNode{Value: ">="},
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{
|
||||
Key: "mtime",
|
||||
Operator: &ast.OperatorNode{Value: "<"},
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{
|
||||
Key: "mtime",
|
||||
Operator: &ast.OperatorNode{Value: "<="},
|
||||
Value: timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
func() query.Query {
|
||||
q := query.NewDateRangeInclusiveQuery(timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), time.Time{}, &[]bool{false}[0], nil)
|
||||
q.FieldVal = "Mtime"
|
||||
return q
|
||||
}(),
|
||||
func() query.Query {
|
||||
q := query.NewDateRangeInclusiveQuery(timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), time.Time{}, &[]bool{true}[0], nil)
|
||||
q.FieldVal = "Mtime"
|
||||
return q
|
||||
}(),
|
||||
func() query.Query {
|
||||
q := query.NewDateRangeInclusiveQuery(time.Time{}, timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), nil, &[]bool{false}[0])
|
||||
q.FieldVal = "Mtime"
|
||||
return q
|
||||
}(),
|
||||
func() query.Query {
|
||||
q := query.NewDateRangeInclusiveQuery(time.Time{}, timeMustParse(t, "2023-09-05T08:42:11.23554+02:00"), nil, &[]bool{true}[0])
|
||||
q.FieldVal = "Mtime"
|
||||
return q
|
||||
}(),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `MimeType:document`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "mediatype", Value: "document"},
|
||||
},
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`MimeType:application/msword`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
|
||||
query.NewQueryStringQuery(`MimeType:text/plain`),
|
||||
query.NewQueryStringQuery(`MimeType:text/markdown`),
|
||||
query.NewQueryStringQuery(`MimeType:application/rtf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `MimeType:document AND *tdd*`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "mediatype", Value: "document"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "name", Value: "*tdd*"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`MimeType:application/msword`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
|
||||
query.NewQueryStringQuery(`MimeType:text/plain`),
|
||||
query.NewQueryStringQuery(`MimeType:text/markdown`),
|
||||
query.NewQueryStringQuery(`MimeType:application/rtf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `MimeType:document OR MimeType:pdf AND *tdd*`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "mediatype", Value: "document"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "mediatype", Value: "pdf"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "name", Value: "*tdd*"},
|
||||
},
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`MimeType:application/msword`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
|
||||
query.NewQueryStringQuery(`MimeType:text/plain`),
|
||||
query.NewQueryStringQuery(`MimeType:text/markdown`),
|
||||
query.NewQueryStringQuery(`MimeType:application/rtf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`MimeType:application/pdf`),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
}),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `(MimeType:document OR MimeType:pdf) AND *tdd*`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "mediatype", Value: "document"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "mediatype", Value: "pdf"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "name", Value: "*tdd*"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`MimeType:application/msword`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
|
||||
query.NewQueryStringQuery(`MimeType:text/plain`),
|
||||
query.NewQueryStringQuery(`MimeType:text/markdown`),
|
||||
query.NewQueryStringQuery(`MimeType:application/rtf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
query.NewQueryStringQuery(`MimeType:application/pdf`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `(MimeType:pdf OR MimeType:document) AND *tdd*`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "mediatype", Value: "pdf"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "mediatype", Value: "document"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "name", Value: "*tdd*"},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`MimeType:application/pdf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/msword`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.document`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.openxmlformats-officedocument.wordprocessingml.form`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.oasis.opendocument.text`),
|
||||
query.NewQueryStringQuery(`MimeType:text/plain`),
|
||||
query.NewQueryStringQuery(`MimeType:text/markdown`),
|
||||
query.NewQueryStringQuery(`MimeType:application/rtf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `John Smith`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "John Smith +-=&|><!(){}[]^\"~: "},
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith\ \+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\ `),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
assert := tAssert.New(t)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := compile(tt.args)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("compile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
assert.Equal(tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_escape(t *testing.T) {
|
||||
type args struct {
|
||||
str string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "all escaped",
|
||||
args: args{
|
||||
`+-=&|><!(){}[]^"~:\/ `,
|
||||
},
|
||||
want: `\+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\\\/\ `,
|
||||
},
|
||||
{
|
||||
name: "no one escaped",
|
||||
args: args{
|
||||
`@#$%`,
|
||||
},
|
||||
want: `@#$%`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tAssert.Equalf(t, tt.want, bleveEscaper.Replace(tt.args.str), "bleveEscaper(%v)", tt.args.str)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/qsfera/server/pkg/ast"
|
||||
)
|
||||
|
||||
// StartsWithBinaryOperatorError records an error and the operation that caused it.
|
||||
type StartsWithBinaryOperatorError struct {
|
||||
Node *ast.OperatorNode
|
||||
}
|
||||
|
||||
func (e StartsWithBinaryOperatorError) Error() string {
|
||||
return "the expression can't begin from a binary operator: '" + e.Node.Value + "'"
|
||||
}
|
||||
|
||||
// NamedGroupInvalidNodesError records an error and the operation that caused it.
|
||||
type NamedGroupInvalidNodesError struct {
|
||||
Node ast.Node
|
||||
}
|
||||
|
||||
func (e NamedGroupInvalidNodesError) Error() string {
|
||||
return fmt.Errorf(
|
||||
"'%T' - '%v' - '%v' is not valid",
|
||||
e.Node,
|
||||
ast.NodeKey(e.Node),
|
||||
ast.NodeValue(e.Node),
|
||||
).Error()
|
||||
}
|
||||
|
||||
// UnsupportedTimeRangeError records an error and the value that caused it.
|
||||
type UnsupportedTimeRangeError struct {
|
||||
Value any
|
||||
}
|
||||
|
||||
func (e UnsupportedTimeRangeError) Error() string {
|
||||
return fmt.Sprintf("unable to convert '%v' to a time range", e.Value)
|
||||
}
|
||||
|
||||
func IsValidationError(err error) bool {
|
||||
switch err.(type) {
|
||||
case *StartsWithBinaryOperatorError, *NamedGroupInvalidNodesError, *UnsupportedTimeRangeError:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package query provides functions to work with the different search query flavours.
|
||||
package query
|
||||
|
||||
import "github.com/qsfera/server/pkg/ast"
|
||||
|
||||
// Builder is the interface that wraps the basic Build method.
|
||||
type Builder interface {
|
||||
Build(qs string) (*ast.Ast, error)
|
||||
}
|
||||
|
||||
// Compiler is the interface that wraps the basic Compile method.
|
||||
type Compiler[T any] interface {
|
||||
Compile(ast *ast.Ast) (T, error)
|
||||
}
|
||||
|
||||
// Creator is the interface that wraps the basic Create method.
|
||||
type Creator[T any] interface {
|
||||
Create(qs string) (T, error)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewBatchOperator creates a new instance of BatchOperator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewBatchOperator(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *BatchOperator {
|
||||
mock := &BatchOperator{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// BatchOperator is an autogenerated mock type for the BatchOperator type
|
||||
type BatchOperator struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type BatchOperator_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *BatchOperator) EXPECT() *BatchOperator_Expecter {
|
||||
return &BatchOperator_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Delete provides a mock function for the type BatchOperator
|
||||
func (_mock *BatchOperator) Delete(id string) error {
|
||||
ret := _mock.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = returnFunc(id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BatchOperator_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
|
||||
type BatchOperator_Delete_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Delete is a helper method to define mock.On call
|
||||
// - id string
|
||||
func (_e *BatchOperator_Expecter) Delete(id interface{}) *BatchOperator_Delete_Call {
|
||||
return &BatchOperator_Delete_Call{Call: _e.mock.On("Delete", id)}
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Delete_Call) Run(run func(id string)) *BatchOperator_Delete_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Delete_Call) Return(err error) *BatchOperator_Delete_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Delete_Call) RunAndReturn(run func(id string) error) *BatchOperator_Delete_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Move provides a mock function for the type BatchOperator
|
||||
func (_mock *BatchOperator) Move(rootID string, parentID string, location string) error {
|
||||
ret := _mock.Called(rootID, parentID, location)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Move")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok {
|
||||
r0 = returnFunc(rootID, parentID, location)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BatchOperator_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move'
|
||||
type BatchOperator_Move_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Move is a helper method to define mock.On call
|
||||
// - rootID string
|
||||
// - parentID string
|
||||
// - location string
|
||||
func (_e *BatchOperator_Expecter) Move(rootID interface{}, parentID interface{}, location interface{}) *BatchOperator_Move_Call {
|
||||
return &BatchOperator_Move_Call{Call: _e.mock.On("Move", rootID, parentID, location)}
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Move_Call) Run(run func(rootID string, parentID string, location string)) *BatchOperator_Move_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Move_Call) Return(err error) *BatchOperator_Move_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Move_Call) RunAndReturn(run func(rootID string, parentID string, location string) error) *BatchOperator_Move_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Purge provides a mock function for the type BatchOperator
|
||||
func (_mock *BatchOperator) Purge(id string, onlyDeleted bool) error {
|
||||
ret := _mock.Called(id, onlyDeleted)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Purge")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok {
|
||||
r0 = returnFunc(id, onlyDeleted)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BatchOperator_Purge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Purge'
|
||||
type BatchOperator_Purge_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Purge is a helper method to define mock.On call
|
||||
// - id string
|
||||
// - onlyDeleted bool
|
||||
func (_e *BatchOperator_Expecter) Purge(id interface{}, onlyDeleted interface{}) *BatchOperator_Purge_Call {
|
||||
return &BatchOperator_Purge_Call{Call: _e.mock.On("Purge", id, onlyDeleted)}
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Purge_Call) Run(run func(id string, onlyDeleted bool)) *BatchOperator_Purge_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 bool
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(bool)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Purge_Call) Return(err error) *BatchOperator_Purge_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Purge_Call) RunAndReturn(run func(id string, onlyDeleted bool) error) *BatchOperator_Purge_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Push provides a mock function for the type BatchOperator
|
||||
func (_mock *BatchOperator) Push() error {
|
||||
ret := _mock.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Push")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = returnFunc()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BatchOperator_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
|
||||
type BatchOperator_Push_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Push is a helper method to define mock.On call
|
||||
func (_e *BatchOperator_Expecter) Push() *BatchOperator_Push_Call {
|
||||
return &BatchOperator_Push_Call{Call: _e.mock.On("Push")}
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Push_Call) Run(run func()) *BatchOperator_Push_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Push_Call) Return(err error) *BatchOperator_Push_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Push_Call) RunAndReturn(run func() error) *BatchOperator_Push_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Restore provides a mock function for the type BatchOperator
|
||||
func (_mock *BatchOperator) Restore(id string) error {
|
||||
ret := _mock.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Restore")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = returnFunc(id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BatchOperator_Restore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restore'
|
||||
type BatchOperator_Restore_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Restore is a helper method to define mock.On call
|
||||
// - id string
|
||||
func (_e *BatchOperator_Expecter) Restore(id interface{}) *BatchOperator_Restore_Call {
|
||||
return &BatchOperator_Restore_Call{Call: _e.mock.On("Restore", id)}
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Restore_Call) Run(run func(id string)) *BatchOperator_Restore_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Restore_Call) Return(err error) *BatchOperator_Restore_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Restore_Call) RunAndReturn(run func(id string) error) *BatchOperator_Restore_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Upsert provides a mock function for the type BatchOperator
|
||||
func (_mock *BatchOperator) Upsert(id string, r search.Resource) error {
|
||||
ret := _mock.Called(id, r)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Upsert")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, search.Resource) error); ok {
|
||||
r0 = returnFunc(id, r)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BatchOperator_Upsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upsert'
|
||||
type BatchOperator_Upsert_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Upsert is a helper method to define mock.On call
|
||||
// - id string
|
||||
// - r search.Resource
|
||||
func (_e *BatchOperator_Expecter) Upsert(id interface{}, r interface{}) *BatchOperator_Upsert_Call {
|
||||
return &BatchOperator_Upsert_Call{Call: _e.mock.On("Upsert", id, r)}
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Upsert_Call) Run(run func(id string, r search.Resource)) *BatchOperator_Upsert_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 search.Resource
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(search.Resource)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Upsert_Call) Return(err error) *BatchOperator_Upsert_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BatchOperator_Upsert_Call) RunAndReturn(run func(id string, r search.Resource) error) *BatchOperator_Upsert_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewEngine creates a new instance of Engine. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewEngine(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Engine {
|
||||
mock := &Engine{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Engine is an autogenerated mock type for the Engine type
|
||||
type Engine struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Engine_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Engine) EXPECT() *Engine_Expecter {
|
||||
return &Engine_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Delete provides a mock function for the type Engine
|
||||
func (_mock *Engine) Delete(id string) error {
|
||||
ret := _mock.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = returnFunc(id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Engine_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
|
||||
type Engine_Delete_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Delete is a helper method to define mock.On call
|
||||
// - id string
|
||||
func (_e *Engine_Expecter) Delete(id interface{}) *Engine_Delete_Call {
|
||||
return &Engine_Delete_Call{Call: _e.mock.On("Delete", id)}
|
||||
}
|
||||
|
||||
func (_c *Engine_Delete_Call) Run(run func(id string)) *Engine_Delete_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Delete_Call) Return(err error) *Engine_Delete_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Delete_Call) RunAndReturn(run func(id string) error) *Engine_Delete_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// DocCount provides a mock function for the type Engine
|
||||
func (_mock *Engine) DocCount() (uint64, error) {
|
||||
ret := _mock.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DocCount")
|
||||
}
|
||||
|
||||
var r0 uint64
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func() (uint64, error)); ok {
|
||||
return returnFunc()
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func() uint64); ok {
|
||||
r0 = returnFunc()
|
||||
} else {
|
||||
r0 = ret.Get(0).(uint64)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func() error); ok {
|
||||
r1 = returnFunc()
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Engine_DocCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DocCount'
|
||||
type Engine_DocCount_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// DocCount is a helper method to define mock.On call
|
||||
func (_e *Engine_Expecter) DocCount() *Engine_DocCount_Call {
|
||||
return &Engine_DocCount_Call{Call: _e.mock.On("DocCount")}
|
||||
}
|
||||
|
||||
func (_c *Engine_DocCount_Call) Run(run func()) *Engine_DocCount_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run()
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_DocCount_Call) Return(v uint64, err error) *Engine_DocCount_Call {
|
||||
_c.Call.Return(v, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_DocCount_Call) RunAndReturn(run func() (uint64, error)) *Engine_DocCount_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Move provides a mock function for the type Engine
|
||||
func (_mock *Engine) Move(id string, parentid string, target string) error {
|
||||
ret := _mock.Called(id, parentid, target)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Move")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, string, string) error); ok {
|
||||
r0 = returnFunc(id, parentid, target)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Engine_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move'
|
||||
type Engine_Move_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Move is a helper method to define mock.On call
|
||||
// - id string
|
||||
// - parentid string
|
||||
// - target string
|
||||
func (_e *Engine_Expecter) Move(id interface{}, parentid interface{}, target interface{}) *Engine_Move_Call {
|
||||
return &Engine_Move_Call{Call: _e.mock.On("Move", id, parentid, target)}
|
||||
}
|
||||
|
||||
func (_c *Engine_Move_Call) Run(run func(id string, parentid string, target string)) *Engine_Move_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Move_Call) Return(err error) *Engine_Move_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Move_Call) RunAndReturn(run func(id string, parentid string, target string) error) *Engine_Move_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewBatch provides a mock function for the type Engine
|
||||
func (_mock *Engine) NewBatch(batchSize int) (search.BatchOperator, error) {
|
||||
ret := _mock.Called(batchSize)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for NewBatch")
|
||||
}
|
||||
|
||||
var r0 search.BatchOperator
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(int) (search.BatchOperator, error)); ok {
|
||||
return returnFunc(batchSize)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(int) search.BatchOperator); ok {
|
||||
r0 = returnFunc(batchSize)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(search.BatchOperator)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(int) error); ok {
|
||||
r1 = returnFunc(batchSize)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Engine_NewBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewBatch'
|
||||
type Engine_NewBatch_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// NewBatch is a helper method to define mock.On call
|
||||
// - batchSize int
|
||||
func (_e *Engine_Expecter) NewBatch(batchSize interface{}) *Engine_NewBatch_Call {
|
||||
return &Engine_NewBatch_Call{Call: _e.mock.On("NewBatch", batchSize)}
|
||||
}
|
||||
|
||||
func (_c *Engine_NewBatch_Call) Run(run func(batchSize int)) *Engine_NewBatch_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 int
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(int)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_NewBatch_Call) Return(batchOperator search.BatchOperator, err error) *Engine_NewBatch_Call {
|
||||
_c.Call.Return(batchOperator, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_NewBatch_Call) RunAndReturn(run func(batchSize int) (search.BatchOperator, error)) *Engine_NewBatch_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Purge provides a mock function for the type Engine
|
||||
func (_mock *Engine) Purge(id string, onlyDeleted bool) error {
|
||||
ret := _mock.Called(id, onlyDeleted)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Purge")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok {
|
||||
r0 = returnFunc(id, onlyDeleted)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Engine_Purge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Purge'
|
||||
type Engine_Purge_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Purge is a helper method to define mock.On call
|
||||
// - id string
|
||||
// - onlyDeleted bool
|
||||
func (_e *Engine_Expecter) Purge(id interface{}, onlyDeleted interface{}) *Engine_Purge_Call {
|
||||
return &Engine_Purge_Call{Call: _e.mock.On("Purge", id, onlyDeleted)}
|
||||
}
|
||||
|
||||
func (_c *Engine_Purge_Call) Run(run func(id string, onlyDeleted bool)) *Engine_Purge_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 bool
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(bool)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Purge_Call) Return(err error) *Engine_Purge_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Purge_Call) RunAndReturn(run func(id string, onlyDeleted bool) error) *Engine_Purge_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Restore provides a mock function for the type Engine
|
||||
func (_mock *Engine) Restore(id string) error {
|
||||
ret := _mock.Called(id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Restore")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = returnFunc(id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Engine_Restore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restore'
|
||||
type Engine_Restore_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Restore is a helper method to define mock.On call
|
||||
// - id string
|
||||
func (_e *Engine_Expecter) Restore(id interface{}) *Engine_Restore_Call {
|
||||
return &Engine_Restore_Call{Call: _e.mock.On("Restore", id)}
|
||||
}
|
||||
|
||||
func (_c *Engine_Restore_Call) Run(run func(id string)) *Engine_Restore_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Restore_Call) Return(err error) *Engine_Restore_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Restore_Call) RunAndReturn(run func(id string) error) *Engine_Restore_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Search provides a mock function for the type Engine
|
||||
func (_mock *Engine) Search(ctx context.Context, req *v0.SearchIndexRequest) (*v0.SearchIndexResponse, error) {
|
||||
ret := _mock.Called(ctx, req)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Search")
|
||||
}
|
||||
|
||||
var r0 *v0.SearchIndexResponse
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchIndexRequest) (*v0.SearchIndexResponse, error)); ok {
|
||||
return returnFunc(ctx, req)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchIndexRequest) *v0.SearchIndexResponse); ok {
|
||||
r0 = returnFunc(ctx, req)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*v0.SearchIndexResponse)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *v0.SearchIndexRequest) error); ok {
|
||||
r1 = returnFunc(ctx, req)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Engine_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search'
|
||||
type Engine_Search_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Search is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - req *v0.SearchIndexRequest
|
||||
func (_e *Engine_Expecter) Search(ctx interface{}, req interface{}) *Engine_Search_Call {
|
||||
return &Engine_Search_Call{Call: _e.mock.On("Search", ctx, req)}
|
||||
}
|
||||
|
||||
func (_c *Engine_Search_Call) Run(run func(ctx context.Context, req *v0.SearchIndexRequest)) *Engine_Search_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *v0.SearchIndexRequest
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*v0.SearchIndexRequest)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Search_Call) Return(searchIndexResponse *v0.SearchIndexResponse, err error) *Engine_Search_Call {
|
||||
_c.Call.Return(searchIndexResponse, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Search_Call) RunAndReturn(run func(ctx context.Context, req *v0.SearchIndexRequest) (*v0.SearchIndexResponse, error)) *Engine_Search_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Upsert provides a mock function for the type Engine
|
||||
func (_mock *Engine) Upsert(id string, r search.Resource) error {
|
||||
ret := _mock.Called(id, r)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Upsert")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(string, search.Resource) error); ok {
|
||||
r0 = returnFunc(id, r)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Engine_Upsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upsert'
|
||||
type Engine_Upsert_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Upsert is a helper method to define mock.On call
|
||||
// - id string
|
||||
// - r search.Resource
|
||||
func (_e *Engine_Expecter) Upsert(id interface{}, r interface{}) *Engine_Upsert_Call {
|
||||
return &Engine_Upsert_Call{Call: _e.mock.On("Upsert", id, r)}
|
||||
}
|
||||
|
||||
func (_c *Engine_Upsert_Call) Run(run func(id string, r search.Resource)) *Engine_Upsert_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 string
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(string)
|
||||
}
|
||||
var arg1 search.Resource
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(search.Resource)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Upsert_Call) Return(err error) *Engine_Upsert_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Engine_Upsert_Call) RunAndReturn(run func(id string, r search.Resource) error) *Engine_Upsert_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewSearcher creates a new instance of Searcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewSearcher(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Searcher {
|
||||
mock := &Searcher{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Searcher is an autogenerated mock type for the Searcher type
|
||||
type Searcher struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Searcher_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Searcher) EXPECT() *Searcher_Expecter {
|
||||
return &Searcher_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// IndexSpace provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) IndexSpace(rID *providerv1beta1.StorageSpaceId, forceRescan bool) error {
|
||||
ret := _mock.Called(rID, forceRescan)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for IndexSpace")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(*providerv1beta1.StorageSpaceId, bool) error); ok {
|
||||
r0 = returnFunc(rID, forceRescan)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Searcher_IndexSpace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IndexSpace'
|
||||
type Searcher_IndexSpace_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// IndexSpace is a helper method to define mock.On call
|
||||
// - rID *providerv1beta1.StorageSpaceId
|
||||
// - forceRescan bool
|
||||
func (_e *Searcher_Expecter) IndexSpace(rID interface{}, forceRescan interface{}) *Searcher_IndexSpace_Call {
|
||||
return &Searcher_IndexSpace_Call{Call: _e.mock.On("IndexSpace", rID, forceRescan)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_IndexSpace_Call) Run(run func(rID *providerv1beta1.StorageSpaceId, forceRescan bool)) *Searcher_IndexSpace_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *providerv1beta1.StorageSpaceId
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*providerv1beta1.StorageSpaceId)
|
||||
}
|
||||
var arg1 bool
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(bool)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_IndexSpace_Call) Return(err error) *Searcher_IndexSpace_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_IndexSpace_Call) RunAndReturn(run func(rID *providerv1beta1.StorageSpaceId, forceRescan bool) error) *Searcher_IndexSpace_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// MoveItem provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) MoveItem(ref *providerv1beta1.Reference) {
|
||||
_mock.Called(ref)
|
||||
return
|
||||
}
|
||||
|
||||
// Searcher_MoveItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MoveItem'
|
||||
type Searcher_MoveItem_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// MoveItem is a helper method to define mock.On call
|
||||
// - ref *providerv1beta1.Reference
|
||||
func (_e *Searcher_Expecter) MoveItem(ref interface{}) *Searcher_MoveItem_Call {
|
||||
return &Searcher_MoveItem_Call{Call: _e.mock.On("MoveItem", ref)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_MoveItem_Call) Run(run func(ref *providerv1beta1.Reference)) *Searcher_MoveItem_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *providerv1beta1.Reference
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*providerv1beta1.Reference)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_MoveItem_Call) Return() *Searcher_MoveItem_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_MoveItem_Call) RunAndReturn(run func(ref *providerv1beta1.Reference)) *Searcher_MoveItem_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// PurgeDeleted provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) PurgeDeleted(spaceID *providerv1beta1.StorageSpaceId) error {
|
||||
ret := _mock.Called(spaceID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for PurgeDeleted")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(*providerv1beta1.StorageSpaceId) error); ok {
|
||||
r0 = returnFunc(spaceID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// Searcher_PurgeDeleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PurgeDeleted'
|
||||
type Searcher_PurgeDeleted_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// PurgeDeleted is a helper method to define mock.On call
|
||||
// - spaceID *providerv1beta1.StorageSpaceId
|
||||
func (_e *Searcher_Expecter) PurgeDeleted(spaceID interface{}) *Searcher_PurgeDeleted_Call {
|
||||
return &Searcher_PurgeDeleted_Call{Call: _e.mock.On("PurgeDeleted", spaceID)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_PurgeDeleted_Call) Run(run func(spaceID *providerv1beta1.StorageSpaceId)) *Searcher_PurgeDeleted_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *providerv1beta1.StorageSpaceId
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*providerv1beta1.StorageSpaceId)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_PurgeDeleted_Call) Return(err error) *Searcher_PurgeDeleted_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_PurgeDeleted_Call) RunAndReturn(run func(spaceID *providerv1beta1.StorageSpaceId) error) *Searcher_PurgeDeleted_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// PurgeItem provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) PurgeItem(rID *providerv1beta1.Reference) {
|
||||
_mock.Called(rID)
|
||||
return
|
||||
}
|
||||
|
||||
// Searcher_PurgeItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PurgeItem'
|
||||
type Searcher_PurgeItem_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// PurgeItem is a helper method to define mock.On call
|
||||
// - rID *providerv1beta1.Reference
|
||||
func (_e *Searcher_Expecter) PurgeItem(rID interface{}) *Searcher_PurgeItem_Call {
|
||||
return &Searcher_PurgeItem_Call{Call: _e.mock.On("PurgeItem", rID)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_PurgeItem_Call) Run(run func(rID *providerv1beta1.Reference)) *Searcher_PurgeItem_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *providerv1beta1.Reference
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*providerv1beta1.Reference)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_PurgeItem_Call) Return() *Searcher_PurgeItem_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_PurgeItem_Call) RunAndReturn(run func(rID *providerv1beta1.Reference)) *Searcher_PurgeItem_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// RestoreItem provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) RestoreItem(ref *providerv1beta1.Reference) {
|
||||
_mock.Called(ref)
|
||||
return
|
||||
}
|
||||
|
||||
// Searcher_RestoreItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestoreItem'
|
||||
type Searcher_RestoreItem_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// RestoreItem is a helper method to define mock.On call
|
||||
// - ref *providerv1beta1.Reference
|
||||
func (_e *Searcher_Expecter) RestoreItem(ref interface{}) *Searcher_RestoreItem_Call {
|
||||
return &Searcher_RestoreItem_Call{Call: _e.mock.On("RestoreItem", ref)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_RestoreItem_Call) Run(run func(ref *providerv1beta1.Reference)) *Searcher_RestoreItem_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *providerv1beta1.Reference
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*providerv1beta1.Reference)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_RestoreItem_Call) Return() *Searcher_RestoreItem_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_RestoreItem_Call) RunAndReturn(run func(ref *providerv1beta1.Reference)) *Searcher_RestoreItem_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Search provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) Search(ctx context.Context, req *v0.SearchRequest) (*v0.SearchResponse, error) {
|
||||
ret := _mock.Called(ctx, req)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Search")
|
||||
}
|
||||
|
||||
var r0 *v0.SearchResponse
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchRequest) (*v0.SearchResponse, error)); ok {
|
||||
return returnFunc(ctx, req)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, *v0.SearchRequest) *v0.SearchResponse); ok {
|
||||
r0 = returnFunc(ctx, req)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*v0.SearchResponse)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, *v0.SearchRequest) error); ok {
|
||||
r1 = returnFunc(ctx, req)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Searcher_Search_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Search'
|
||||
type Searcher_Search_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Search is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - req *v0.SearchRequest
|
||||
func (_e *Searcher_Expecter) Search(ctx interface{}, req interface{}) *Searcher_Search_Call {
|
||||
return &Searcher_Search_Call{Call: _e.mock.On("Search", ctx, req)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_Search_Call) Run(run func(ctx context.Context, req *v0.SearchRequest)) *Searcher_Search_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 *v0.SearchRequest
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*v0.SearchRequest)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_Search_Call) Return(searchResponse *v0.SearchResponse, err error) *Searcher_Search_Call {
|
||||
_c.Call.Return(searchResponse, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_Search_Call) RunAndReturn(run func(ctx context.Context, req *v0.SearchRequest) (*v0.SearchResponse, error)) *Searcher_Search_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// TrashItem provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) TrashItem(rID *providerv1beta1.ResourceId) {
|
||||
_mock.Called(rID)
|
||||
return
|
||||
}
|
||||
|
||||
// Searcher_TrashItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TrashItem'
|
||||
type Searcher_TrashItem_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// TrashItem is a helper method to define mock.On call
|
||||
// - rID *providerv1beta1.ResourceId
|
||||
func (_e *Searcher_Expecter) TrashItem(rID interface{}) *Searcher_TrashItem_Call {
|
||||
return &Searcher_TrashItem_Call{Call: _e.mock.On("TrashItem", rID)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_TrashItem_Call) Run(run func(rID *providerv1beta1.ResourceId)) *Searcher_TrashItem_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *providerv1beta1.ResourceId
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*providerv1beta1.ResourceId)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_TrashItem_Call) Return() *Searcher_TrashItem_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_TrashItem_Call) RunAndReturn(run func(rID *providerv1beta1.ResourceId)) *Searcher_TrashItem_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpsertItem provides a mock function for the type Searcher
|
||||
func (_mock *Searcher) UpsertItem(ref *providerv1beta1.Reference) {
|
||||
_mock.Called(ref)
|
||||
return
|
||||
}
|
||||
|
||||
// Searcher_UpsertItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertItem'
|
||||
type Searcher_UpsertItem_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// UpsertItem is a helper method to define mock.On call
|
||||
// - ref *providerv1beta1.Reference
|
||||
func (_e *Searcher_Expecter) UpsertItem(ref interface{}) *Searcher_UpsertItem_Call {
|
||||
return &Searcher_UpsertItem_Call{Call: _e.mock.On("UpsertItem", ref)}
|
||||
}
|
||||
|
||||
func (_c *Searcher_UpsertItem_Call) Run(run func(ref *providerv1beta1.Reference)) *Searcher_UpsertItem_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 *providerv1beta1.Reference
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(*providerv1beta1.Reference)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_UpsertItem_Call) Return() *Searcher_UpsertItem_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Searcher_UpsertItem_Call) RunAndReturn(run func(ref *providerv1beta1.Reference)) *Searcher_UpsertItem_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/conversions"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/grants"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchService "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
)
|
||||
|
||||
var scopeRegex = regexp.MustCompile(`scope:\s*([^" "\n\r]*)`)
|
||||
|
||||
// Engine is the interface to the search engine
|
||||
type Engine interface {
|
||||
Search(ctx context.Context, req *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error)
|
||||
DocCount() (uint64, error)
|
||||
|
||||
Upsert(id string, r Resource) error
|
||||
Move(id string, parentid string, target string) error
|
||||
Delete(id string) error
|
||||
Restore(id string) error
|
||||
Purge(id string, onlyDeleted bool) error
|
||||
|
||||
NewBatch(batchSize int) (BatchOperator, error)
|
||||
}
|
||||
|
||||
type BatchOperator interface {
|
||||
Upsert(id string, r Resource) error
|
||||
Move(rootID, parentID, location string) error
|
||||
Delete(id string) error
|
||||
Restore(id string) error
|
||||
Purge(id string, onlyDeleted bool) error
|
||||
|
||||
Push() error
|
||||
}
|
||||
|
||||
// Resource is the entity that is stored in the index.
|
||||
type Resource struct {
|
||||
content.Document
|
||||
|
||||
ID string
|
||||
RootID string
|
||||
Path string
|
||||
ParentID string
|
||||
Type uint64
|
||||
Deleted bool
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
// ResolveReference makes sure the path is relative to the space root
|
||||
func ResolveReference(ctx context.Context, ref *provider.Reference, ri *provider.ResourceInfo, gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) (*provider.Reference, error) {
|
||||
if ref.GetResourceId().GetOpaqueId() == ref.GetResourceId().GetSpaceId() {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
gatewayClient, err := gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gpRes, err := gatewayClient.GetPath(ctx, &provider.GetPathRequest{
|
||||
ResourceId: ri.GetId(),
|
||||
})
|
||||
if err != nil || gpRes.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
return nil, err
|
||||
}
|
||||
return &provider.Reference{
|
||||
ResourceId: &provider.ResourceId{
|
||||
StorageId: ref.GetResourceId().GetStorageId(),
|
||||
SpaceId: ref.GetResourceId().GetSpaceId(),
|
||||
OpaqueId: ref.GetResourceId().GetSpaceId(),
|
||||
},
|
||||
Path: utils.MakeRelativePath(gpRes.GetPath()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type matchArray []*searchmsg.Match
|
||||
|
||||
func (ma matchArray) Len() int {
|
||||
return len(ma)
|
||||
}
|
||||
func (ma matchArray) Swap(i, j int) {
|
||||
ma[i], ma[j] = ma[j], ma[i]
|
||||
}
|
||||
func (ma matchArray) Less(i, j int) bool {
|
||||
return ma[i].GetScore() > ma[j].GetScore()
|
||||
}
|
||||
|
||||
func logDocCount(engine Engine, logger log.Logger) {
|
||||
c, err := engine.DocCount()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("error getting document count from the index")
|
||||
}
|
||||
logger.Debug().Interface("count", c).Msg("new document count")
|
||||
}
|
||||
|
||||
func getAuthContext(serviceAccountID string, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], secret string, logger log.Logger) (context.Context, error) {
|
||||
gatewayClient, err := gatewaySelector.Next()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("could not get reva gatewayClient")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return utils.GetServiceUserContext(serviceAccountID, gatewayClient, secret)
|
||||
}
|
||||
|
||||
func statResource(ctx context.Context, ref *provider.Reference, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], logger log.Logger) (*provider.StatResponse, error) {
|
||||
gatewayClient, err := gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := gatewayClient.Stat(ctx, &provider.StatRequest{Ref: ref})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to stat the moved resource")
|
||||
return nil, err
|
||||
}
|
||||
switch res.GetStatus().GetCode() {
|
||||
case rpc.Code_CODE_OK:
|
||||
return res, nil
|
||||
case rpc.Code_CODE_NOT_FOUND:
|
||||
// Resource was moved or deleted in the meantime. ignore.
|
||||
return nil, err
|
||||
default:
|
||||
err := errors.New("failed to stat the moved resource")
|
||||
logger.Error().Interface("res", res).Msg(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: this converts CS3 to WebDAV permissions
|
||||
// since conversions pkg is reva internal we have no other choice than to duplicate the logic
|
||||
func convertToWebDAVPermissions(isShared, isMountpoint, isDir bool, p *provider.ResourcePermissions) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
if isShared {
|
||||
fmt.Fprintf(&b, "S")
|
||||
}
|
||||
if p.GetListContainer() &&
|
||||
p.GetListFileVersions() &&
|
||||
p.GetListRecycle() &&
|
||||
p.GetStat() &&
|
||||
p.GetGetPath() &&
|
||||
p.GetGetQuota() &&
|
||||
p.GetInitiateFileDownload() {
|
||||
fmt.Fprintf(&b, "R")
|
||||
}
|
||||
if isMountpoint {
|
||||
fmt.Fprintf(&b, "M")
|
||||
}
|
||||
if p.GetDelete() {
|
||||
fmt.Fprintf(&b, "D")
|
||||
}
|
||||
if p.GetInitiateFileUpload() &&
|
||||
p.GetRestoreFileVersion() &&
|
||||
p.GetRestoreRecycleItem() {
|
||||
fmt.Fprintf(&b, "NV")
|
||||
if !isDir {
|
||||
fmt.Fprintf(&b, "W")
|
||||
}
|
||||
}
|
||||
if isDir &&
|
||||
p.GetListContainer() &&
|
||||
p.GetStat() &&
|
||||
p.GetCreateContainer() &&
|
||||
p.GetInitiateFileUpload() {
|
||||
fmt.Fprintf(&b, "CK")
|
||||
}
|
||||
if grants.PermissionsEqual(p, conversions.NewSecureViewerRole().CS3ResourcePermissions()) {
|
||||
fmt.Fprintf(&b, "X")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ParseScope extract a scope value from the query string and returns search, scope strings
|
||||
func ParseScope(query string) (string, string) {
|
||||
match := scopeRegex.FindStringSubmatch(query)
|
||||
if len(match) >= 2 {
|
||||
cut := match[0]
|
||||
return strings.TrimSpace(strings.ReplaceAll(query, cut, "")), strings.TrimSpace(match[1])
|
||||
}
|
||||
return query, ""
|
||||
}
|
||||
|
||||
// ParseFlags extracts supported flags from the query string and returns the cleaned query and a map of flags
|
||||
func ParseFlags(query string) (string, []string) {
|
||||
supportedFlags := []string{"is:favorite"}
|
||||
flags := []string{}
|
||||
for _, flag := range supportedFlags {
|
||||
if strings.Contains(query, flag) {
|
||||
flags = append(flags, flag)
|
||||
query = strings.ReplaceAll(query, flag, "")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(query), flags
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package search_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
mRegistry "go-micro.dev/v4/registry"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func init() {
|
||||
r := registry.GetRegistry(registry.Inmemory())
|
||||
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
|
||||
service.Nodes = []*mRegistry.Node{{
|
||||
Address: "any",
|
||||
}}
|
||||
|
||||
_ = r.Register(service)
|
||||
}
|
||||
func TestSearch(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Search Suite")
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
sdk "github.com/opencloud-eu/reva/v2/pkg/sdk/common"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/walker"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
"github.com/qsfera/server/services/search/pkg/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
_spaceStateTrashed = "trashed"
|
||||
_spaceTypeMountpoint = "mountpoint"
|
||||
_spaceTypePersonal = "personal"
|
||||
_spaceTypeProject = "project"
|
||||
_spaceTypeGrant = "grant"
|
||||
_slowQueryDuration = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Searcher is the interface to the SearchService
|
||||
type Searcher interface {
|
||||
Search(ctx context.Context, req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error)
|
||||
|
||||
IndexSpace(rID *provider.StorageSpaceId, forceRescan bool) error
|
||||
PurgeDeleted(spaceID *provider.StorageSpaceId) error
|
||||
|
||||
TrashItem(rID *provider.ResourceId)
|
||||
PurgeItem(rID *provider.Reference)
|
||||
UpsertItem(ref *provider.Reference)
|
||||
RestoreItem(ref *provider.Reference)
|
||||
MoveItem(ref *provider.Reference)
|
||||
}
|
||||
|
||||
// Service is responsible for indexing spaces and pass on a search
|
||||
// to it's underlying engine.
|
||||
type Service struct {
|
||||
logger log.Logger
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
engine Engine
|
||||
extractor content.Extractor
|
||||
metrics *metrics.Metrics
|
||||
|
||||
serviceAccountID string
|
||||
serviceAccountSecret string
|
||||
|
||||
batchSize int
|
||||
}
|
||||
|
||||
var errSkipSpace error
|
||||
|
||||
// NewService creates a new Provider instance.
|
||||
func NewService(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], eng Engine, extractor content.Extractor, metrics *metrics.Metrics, logger log.Logger, cfg *config.Config) *Service {
|
||||
var s = &Service{
|
||||
gatewaySelector: gatewaySelector,
|
||||
engine: eng,
|
||||
logger: logger,
|
||||
extractor: extractor,
|
||||
metrics: metrics,
|
||||
|
||||
serviceAccountID: cfg.ServiceAccount.ServiceAccountID,
|
||||
serviceAccountSecret: cfg.ServiceAccount.ServiceAccountSecret,
|
||||
|
||||
batchSize: cfg.BatchSize,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Search processes a search request and passes it down to the engine.
|
||||
func (s *Service) Search(ctx context.Context, req *searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
|
||||
s.logger.Debug().Str("query", req.Query).Msg("performing a search")
|
||||
|
||||
// collect metrics
|
||||
startTime := time.Now()
|
||||
success := false
|
||||
defer func() {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if !success {
|
||||
status = "error"
|
||||
}
|
||||
s.metrics.SearchDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
|
||||
}()
|
||||
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentUser := revactx.ContextMustGetUser(ctx)
|
||||
|
||||
// Handle flags
|
||||
query, flags := ParseFlags(req.Query)
|
||||
for _, flag := range flags {
|
||||
switch flag {
|
||||
case "is:favorite":
|
||||
query += " Favorites:\"" + currentUser.GetId().GetOpaqueId() + "\""
|
||||
}
|
||||
}
|
||||
|
||||
// Extract scope from query if set
|
||||
query, scope := ParseScope(query)
|
||||
if query == "" {
|
||||
return nil, errtypes.BadRequest("empty query provided")
|
||||
}
|
||||
req.Query = query
|
||||
if len(scope) > 0 {
|
||||
scopedID, err := storagespace.ParseID(scope)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to parse scope")
|
||||
}
|
||||
|
||||
// Stat the scope to get the resource id
|
||||
statRes, err := gatewayClient.Stat(ctx, &provider.StatRequest{
|
||||
Ref: &provider.Reference{
|
||||
ResourceId: &scopedID,
|
||||
},
|
||||
FieldMask: &fieldmaskpb.FieldMask{Paths: []string{"space"}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// GetPath the scope to get the full path in the space
|
||||
gpRes, err := gatewayClient.GetPath(ctx, &provider.GetPathRequest{
|
||||
ResourceId: statRes.GetInfo().GetId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Ref = &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: statRes.GetInfo().GetSpace().GetRoot().GetStorageId(),
|
||||
SpaceId: statRes.GetInfo().GetSpace().GetRoot().GetSpaceId(),
|
||||
OpaqueId: statRes.GetInfo().GetSpace().GetRoot().GetOpaqueId(),
|
||||
},
|
||||
Path: gpRes.Path,
|
||||
}
|
||||
}
|
||||
filters := []*provider.ListStorageSpacesRequest_Filter{
|
||||
{
|
||||
Type: provider.ListStorageSpacesRequest_Filter_TYPE_USER,
|
||||
Term: &provider.ListStorageSpacesRequest_Filter_User{User: currentUser.GetId()},
|
||||
},
|
||||
{
|
||||
Type: provider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
|
||||
Term: &provider.ListStorageSpacesRequest_Filter_SpaceType{SpaceType: "+grant"},
|
||||
},
|
||||
}
|
||||
|
||||
// Get the spaces to search
|
||||
spaces := []*provider.StorageSpace{}
|
||||
listSpacesRes, err := gatewayClient.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{Filters: filters})
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to list the user's storage spaces")
|
||||
return nil, err
|
||||
}
|
||||
for _, space := range listSpacesRes.StorageSpaces {
|
||||
if utils.ReadPlainFromOpaque(space.Opaque, "trashed") == _spaceStateTrashed {
|
||||
// Do not consider disabled spaces
|
||||
continue
|
||||
}
|
||||
if space.SpaceType != "mountpoint" && req.Ref != nil && (req.Ref.GetResourceId().GetSpaceId() != space.Root.GetSpaceId()) {
|
||||
// Do not search (non-mountpoint) spaces that do not match the given scope (if a scope is set)
|
||||
// We still need the mountpoint in order to map the result paths to the according share
|
||||
continue
|
||||
}
|
||||
spaces = append(spaces, space)
|
||||
}
|
||||
|
||||
mountpointMap := map[string]string{}
|
||||
for _, space := range spaces {
|
||||
if space.SpaceType != _spaceTypeMountpoint {
|
||||
continue
|
||||
}
|
||||
opaqueMap := sdk.DecodeOpaqueMap(space.Opaque)
|
||||
grantSpaceID := storagespace.FormatResourceID(&provider.ResourceId{
|
||||
StorageId: opaqueMap["grantStorageID"],
|
||||
SpaceId: opaqueMap["grantSpaceID"],
|
||||
OpaqueId: opaqueMap["grantOpaqueID"],
|
||||
})
|
||||
mountpointMap[grantSpaceID] = space.Id.OpaqueId
|
||||
}
|
||||
|
||||
matches := matchArray{}
|
||||
total := int32(0)
|
||||
|
||||
errg, ctx := errgroup.WithContext(ctx)
|
||||
work := make(chan *provider.StorageSpace, len(spaces))
|
||||
results := make(chan *searchsvc.SearchIndexResponse, len(spaces))
|
||||
|
||||
// Distribute work
|
||||
errg.Go(func() error {
|
||||
defer close(work)
|
||||
for _, space := range spaces {
|
||||
select {
|
||||
case work <- space:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Spawn workers that'll concurrently work the queue
|
||||
numWorkers := 20
|
||||
if len(spaces) < numWorkers {
|
||||
numWorkers = len(spaces)
|
||||
}
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
errg.Go(func() error {
|
||||
for space := range work {
|
||||
res, err := s.searchIndex(ctx, req, space, mountpointMap[space.Id.OpaqueId])
|
||||
if err != nil && err != errSkipSpace {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case results <- res:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for things to settle down, then close results chan
|
||||
go func() {
|
||||
_ = errg.Wait() // error is checked later
|
||||
close(results)
|
||||
}()
|
||||
|
||||
responses := make([]*searchsvc.SearchIndexResponse, len(spaces))
|
||||
i := 0
|
||||
for r := range results {
|
||||
responses[i] = r
|
||||
i++
|
||||
}
|
||||
|
||||
if err := errg.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, res := range responses {
|
||||
if res == nil {
|
||||
continue
|
||||
}
|
||||
total += res.TotalMatches
|
||||
for _, match := range res.Matches {
|
||||
matches = append(matches, match)
|
||||
}
|
||||
}
|
||||
|
||||
// compile one sorted list of matches from all spaces and apply the limit if needed
|
||||
sort.Sort(matches)
|
||||
limit := req.PageSize
|
||||
if limit == 0 {
|
||||
limit = 200
|
||||
}
|
||||
if int32(len(matches)) > limit && limit != -1 {
|
||||
matches = matches[0:limit]
|
||||
}
|
||||
|
||||
success = true
|
||||
return &searchsvc.SearchResponse{
|
||||
Matches: matches,
|
||||
TotalMatches: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) searchIndex(ctx context.Context, req *searchsvc.SearchRequest, space *provider.StorageSpace, mountpointID string) (*searchsvc.SearchIndexResponse, error) {
|
||||
if req.Ref != nil &&
|
||||
(req.Ref.ResourceId.StorageId != space.Root.StorageId ||
|
||||
req.Ref.ResourceId.SpaceId != space.Root.SpaceId) {
|
||||
return nil, errSkipSpace
|
||||
}
|
||||
|
||||
searchRootID := &searchmsg.ResourceID{
|
||||
StorageId: space.Root.StorageId,
|
||||
SpaceId: space.Root.SpaceId,
|
||||
OpaqueId: space.Root.OpaqueId,
|
||||
}
|
||||
|
||||
var (
|
||||
mountpointRootID *searchmsg.ResourceID
|
||||
rootName string
|
||||
permissions *provider.ResourcePermissions
|
||||
remoteItemId *searchmsg.ResourceID
|
||||
)
|
||||
mountpointPrefix := ""
|
||||
searchPathPrefix := req.Ref.GetPath()
|
||||
switch space.SpaceType {
|
||||
case _spaceTypeMountpoint:
|
||||
return nil, errSkipSpace // mountpoint spaces are only "links" to the shared spaces. we have to search the shared "grant" space instead
|
||||
case _spaceTypeGrant:
|
||||
// In case of grant spaces we search the root of the outer space and translate the paths to the according mountpoint
|
||||
searchRootID.OpaqueId = space.Root.SpaceId
|
||||
if mountpointID == "" {
|
||||
s.logger.Warn().Interface("space", space).Msg("could not find mountpoint space for grant space")
|
||||
return nil, errSkipSpace
|
||||
}
|
||||
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serviceCtx, err := getAuthContext(s.serviceAccountID, s.gatewaySelector, s.serviceAccountSecret, s.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gpRes, err := gatewayClient.GetPath(serviceCtx, &provider.GetPathRequest{
|
||||
ResourceId: space.Root,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("space", space.Id.OpaqueId).Msg("failed to get path for grant space root")
|
||||
return nil, errSkipSpace
|
||||
}
|
||||
if gpRes.Status.Code != rpcv1beta1.Code_CODE_OK {
|
||||
s.logger.Error().Interface("status", gpRes.Status).Str("space", space.Id.OpaqueId).Msg("failed to get path for grant space root")
|
||||
return nil, errSkipSpace
|
||||
}
|
||||
mountpointPrefix = utils.MakeRelativePath(gpRes.Path)
|
||||
if searchPathPrefix == "" {
|
||||
searchPathPrefix = mountpointPrefix
|
||||
}
|
||||
sid, spid, oid, err := storagespace.SplitID(mountpointID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("space", space.Id.OpaqueId).Str("mountpointId", mountpointID).Msg("invalid mountpoint space id")
|
||||
return nil, errSkipSpace
|
||||
}
|
||||
// exclude the hidden shares
|
||||
rs, err := gatewayClient.GetReceivedShare(ctx, &collaborationv1beta1.GetReceivedShareRequest{
|
||||
Ref: &collaborationv1beta1.ShareReference{
|
||||
Spec: &collaborationv1beta1.ShareReference_Id{
|
||||
Id: &collaborationv1beta1.ShareId{
|
||||
OpaqueId: oid,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("space", space.Id.OpaqueId).Str("shareId", oid).Msg("invalid receive share")
|
||||
}
|
||||
if rs.GetStatus().GetCode() == rpcv1beta1.Code_CODE_OK && rs.GetShare().GetHidden() {
|
||||
return nil, errSkipSpace
|
||||
}
|
||||
|
||||
mountpointRootID = &searchmsg.ResourceID{
|
||||
StorageId: sid,
|
||||
SpaceId: spid,
|
||||
OpaqueId: oid,
|
||||
}
|
||||
rootName = space.GetRootInfo().GetPath()
|
||||
permissions = space.GetRootInfo().GetPermissionSet()
|
||||
remoteItemId = &searchmsg.ResourceID{
|
||||
StorageId: space.GetRootInfo().GetId().GetStorageId(),
|
||||
SpaceId: space.GetRootInfo().GetId().GetSpaceId(),
|
||||
OpaqueId: space.GetRootInfo().GetId().GetOpaqueId(),
|
||||
}
|
||||
s.logger.Debug().Interface("grantSpace", space).Interface("mountpointRootId", mountpointRootID).Msg("searching a grant")
|
||||
case _spaceTypePersonal, _spaceTypeProject:
|
||||
permissions = space.GetRootInfo().GetPermissionSet()
|
||||
}
|
||||
|
||||
searchRequest := &searchsvc.SearchIndexRequest{
|
||||
Query: req.Query,
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: searchRootID,
|
||||
Path: searchPathPrefix,
|
||||
},
|
||||
PageSize: req.PageSize,
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := s.engine.Search(ctx, searchRequest)
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("duration", fmt.Sprint(duration)).Str("space", space.Id.OpaqueId).Msg("failed to search the index")
|
||||
return nil, err
|
||||
}
|
||||
if duration > _slowQueryDuration {
|
||||
s.logger.Info().Interface("searchRequest", searchRequest).Str("duration", fmt.Sprint(duration)).Str("space", space.Id.OpaqueId).Int("hits", len(res.Matches)).Msg("slow space search")
|
||||
} else {
|
||||
s.logger.Debug().Interface("searchRequest", searchRequest).Str("duration", fmt.Sprint(duration)).Str("space", space.Id.OpaqueId).Int("hits", len(res.Matches)).Msg("space search done")
|
||||
}
|
||||
|
||||
matches := make([]*searchmsg.Match, 0, len(res.Matches))
|
||||
|
||||
for _, match := range res.Matches {
|
||||
if mountpointPrefix != "" {
|
||||
match.Entity.Ref.Path = utils.MakeRelativePath(strings.TrimPrefix(match.Entity.Ref.Path, mountpointPrefix))
|
||||
}
|
||||
if mountpointRootID != nil {
|
||||
match.Entity.Ref.ResourceId = mountpointRootID
|
||||
}
|
||||
match.Entity.ShareRootName = rootName
|
||||
match.Entity.RemoteItemId = remoteItemId
|
||||
|
||||
isShared := match.GetEntity().GetRef().GetResourceId().GetSpaceId() == utils.ShareStorageSpaceID
|
||||
isMountpoint := isShared && match.GetEntity().GetRef().GetPath() == "."
|
||||
isDir := match.GetEntity().GetMimeType() == "httpd/unix-directory"
|
||||
match.Entity.Permissions = convertToWebDAVPermissions(isShared, isMountpoint, isDir, permissions)
|
||||
|
||||
if req.Ref != nil && searchPathPrefix == "/"+match.Entity.Name {
|
||||
continue
|
||||
}
|
||||
|
||||
matches = append(matches, match)
|
||||
}
|
||||
|
||||
res.Matches = matches
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// IndexSpace (re)indexes all resources of a given space.
|
||||
func (s *Service) IndexSpace(spaceID *provider.StorageSpaceId, forceRescan bool) error {
|
||||
ownerCtx, err := getAuthContext(s.serviceAccountID, s.gatewaySelector, s.serviceAccountSecret, s.logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rootID, err := storagespace.ParseID(spaceID.OpaqueId)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("invalid space id")
|
||||
return err
|
||||
}
|
||||
if rootID.StorageId == "" || rootID.SpaceId == "" {
|
||||
s.logger.Error().Err(err).Msg("invalid space id")
|
||||
return fmt.Errorf("invalid space id")
|
||||
}
|
||||
rootID.OpaqueId = rootID.SpaceId
|
||||
|
||||
// Collect metrics
|
||||
startTime := time.Now()
|
||||
success := false
|
||||
defer func() {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
status := "success"
|
||||
if !success {
|
||||
status = "error"
|
||||
}
|
||||
s.metrics.IndexDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
|
||||
}()
|
||||
|
||||
w := walker.NewWalker(s.gatewaySelector)
|
||||
batch, err := s.engine.NewBatch(s.batchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := batch.Push(); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to end batch")
|
||||
}
|
||||
logDocCount(s.engine, s.logger)
|
||||
}()
|
||||
err = w.Walk(ownerCtx, &rootID, func(wd string, info *provider.ResourceInfo, err error) error {
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("error walking the tree")
|
||||
return err
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ref := &provider.Reference{
|
||||
Path: utils.MakeRelativePath(filepath.Join(wd, info.Path)),
|
||||
ResourceId: &rootID,
|
||||
}
|
||||
s.logger.Debug().Str("path", ref.Path).Msg("Walking tree")
|
||||
|
||||
if forceRescan {
|
||||
s.doUpsertItem(ref, batch)
|
||||
return nil
|
||||
}
|
||||
|
||||
searchRes, err := s.engine.Search(ownerCtx, &searchsvc.SearchIndexRequest{
|
||||
Query: "id:" + storagespace.FormatResourceID(info.Id) + ` mtime>=` + utils.TSToTime(info.Mtime).Format(time.RFC3339Nano),
|
||||
})
|
||||
|
||||
if err == nil && len(searchRes.Matches) >= 1 {
|
||||
if info.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER {
|
||||
s.logger.Debug().Str("path", ref.Path).Msg("subtree hasn't changed. Skipping.")
|
||||
return filepath.SkipDir
|
||||
}
|
||||
s.logger.Debug().Str("path", ref.Path).Msg("element hasn't changed. Skipping.")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.doUpsertItem(ref, batch)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TrashItem marks the item as deleted.
|
||||
func (s *Service) TrashItem(rID *provider.ResourceId) {
|
||||
if err := s.engine.Delete(storagespace.FormatResourceID(rID)); err != nil {
|
||||
s.logger.Error().Err(err).Interface("Id", rID).Msg("failed to remove item from index")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PurgeItem(ref *provider.Reference) {
|
||||
if ref.Path != "" && ref.Path != "." {
|
||||
s.logger.Warn().Str("path", ref.Path).Msg("purging an item with a path is not supported")
|
||||
return
|
||||
}
|
||||
|
||||
err := s.engine.Purge(storagespace.FormatResourceID(ref.ResourceId), false)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Interface("Id", ref.ResourceId).Msg("failed to purge item from index")
|
||||
return
|
||||
}
|
||||
s.logger.Info().Interface("Id", ref.ResourceId).Msg("purged item from index")
|
||||
logDocCount(s.engine, s.logger)
|
||||
}
|
||||
|
||||
func (s *Service) PurgeDeleted(spaceID *provider.StorageSpaceId) error {
|
||||
if spaceID == nil {
|
||||
return fmt.Errorf("spaceID must not be nil")
|
||||
}
|
||||
|
||||
rootID, err := storagespace.ParseID(spaceID.OpaqueId)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("invalid space id")
|
||||
return err
|
||||
}
|
||||
if rootID.StorageId == "" || rootID.SpaceId == "" {
|
||||
s.logger.Error().Err(err).Msg("invalid space id")
|
||||
return fmt.Errorf("invalid space id")
|
||||
}
|
||||
rootID.OpaqueId = rootID.SpaceId
|
||||
|
||||
if err := s.engine.Purge(storagespace.FormatResourceID(&rootID), true); err != nil {
|
||||
s.logger.Error().Err(err).Interface("Id", &rootID).Msg("failed to purge deleted items from index")
|
||||
return err
|
||||
}
|
||||
|
||||
logDocCount(s.engine, s.logger)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertItem indexes or stores Resource data fields.
|
||||
func (s *Service) UpsertItem(ref *provider.Reference) {
|
||||
s.doUpsertItem(ref, nil)
|
||||
}
|
||||
|
||||
// doUpsertItem indexes or stores Resource data fields.
|
||||
func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) {
|
||||
ctx, stat, path := s.resInfo(ref)
|
||||
if ctx == nil || stat == nil || path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.extractor.Extract(ctx, stat.Info)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to extract resource content")
|
||||
return
|
||||
}
|
||||
|
||||
r := Resource{
|
||||
ID: storagespace.FormatResourceID(stat.Info.Id),
|
||||
RootID: storagespace.FormatResourceID(&provider.ResourceId{
|
||||
StorageId: stat.Info.Id.StorageId,
|
||||
OpaqueId: stat.Info.Id.SpaceId,
|
||||
SpaceId: stat.Info.Id.SpaceId,
|
||||
}),
|
||||
Path: utils.MakeRelativePath(path),
|
||||
Type: uint64(stat.Info.Type),
|
||||
Document: doc,
|
||||
}
|
||||
r.Hidden = strings.HasPrefix(r.Path, ".")
|
||||
|
||||
if parentID := stat.GetInfo().GetParentId(); parentID != nil {
|
||||
r.ParentID = storagespace.FormatResourceID(parentID)
|
||||
}
|
||||
|
||||
if batch != nil {
|
||||
err = batch.Upsert(r.ID, r)
|
||||
} else {
|
||||
err = s.engine.Upsert(r.ID, r)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("error adding updating the resource in the index")
|
||||
} else {
|
||||
logDocCount(s.engine, s.logger)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if len(metadata) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Trace().Str("name", doc.Name).Interface("metadata", metadata).Msg("Storing metadata")
|
||||
|
||||
gatewayClient, err := s.gatewaySelector.Next()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("could not retrieve client to store metadata")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := gatewayClient.SetArbitraryMetadata(ctx, &provider.SetArbitraryMetadataRequest{
|
||||
Ref: ref,
|
||||
ArbitraryMetadata: &provider.ArbitraryMetadata{
|
||||
Metadata: metadata,
|
||||
},
|
||||
})
|
||||
if err != nil || resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
s.logger.Error().Err(err).Int32("status", int32(resp.GetStatus().GetCode())).Msg("error storing metadata")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func addAudioMetadata(metadata map[string]string, audio *libregraph.Audio) {
|
||||
if audio == nil {
|
||||
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()
|
||||
|
||||
for k, v := range m {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
target[prefix+k] = valueToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
func valueToString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case *string:
|
||||
return *v
|
||||
case *int32:
|
||||
return strconv.FormatInt(int64(*v), 10)
|
||||
case *int64:
|
||||
return strconv.FormatInt(*v, 10)
|
||||
case *float32:
|
||||
return strconv.FormatFloat(float64(*v), 'f', -1, 32)
|
||||
case *float64:
|
||||
return strconv.FormatFloat(*v, 'f', -1, 64)
|
||||
case *bool:
|
||||
return strconv.FormatBool(*v)
|
||||
case *time.Time:
|
||||
return v.Format(time.RFC3339)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreItem makes the item available again.
|
||||
func (s *Service) RestoreItem(ref *provider.Reference) {
|
||||
ctx, stat, path := s.resInfo(ref)
|
||||
if ctx == nil || stat == nil || path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.engine.Restore(storagespace.FormatResourceID(stat.Info.Id)); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to restore the changed resource in the index")
|
||||
}
|
||||
}
|
||||
|
||||
// MoveItem updates the resource location and all of its necessary fields.
|
||||
func (s *Service) MoveItem(ref *provider.Reference) {
|
||||
ctx, stat, path := s.resInfo(ref)
|
||||
if ctx == nil || stat == nil || path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.engine.Move(storagespace.FormatResourceID(stat.GetInfo().GetId()), storagespace.FormatResourceID(stat.GetInfo().GetParentId()), path); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to move the changed resource in the index")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) resInfo(ref *provider.Reference) (context.Context, *provider.StatResponse, string) {
|
||||
ownerCtx, err := getAuthContext(s.serviceAccountID, s.gatewaySelector, s.serviceAccountSecret, s.logger)
|
||||
if err != nil {
|
||||
return nil, nil, ""
|
||||
}
|
||||
|
||||
statRes, err := statResource(ownerCtx, ref, s.gatewaySelector, s.logger)
|
||||
if err != nil {
|
||||
return nil, nil, ""
|
||||
}
|
||||
|
||||
r, err := ResolveReference(ownerCtx, ref, statRes.GetInfo(), s.gatewaySelector)
|
||||
if err != nil {
|
||||
return nil, nil, ""
|
||||
}
|
||||
|
||||
return ownerCtx, statRes, r.GetPath()
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package search_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
collaborationv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
|
||||
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
searchmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/content"
|
||||
contentMocks "github.com/qsfera/server/services/search/pkg/content/mocks"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
engineMocks "github.com/qsfera/server/services/search/pkg/search/mocks"
|
||||
)
|
||||
|
||||
var _ = Describe("Searchprovider", func() {
|
||||
var (
|
||||
s search.Searcher
|
||||
extractor *contentMocks.Extractor
|
||||
gatewayClient *cs3mocks.GatewayAPIClient
|
||||
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
indexClient *engineMocks.Engine
|
||||
ctx context.Context
|
||||
logger = log.NewLogger()
|
||||
user = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
otherUser = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "otheruser",
|
||||
},
|
||||
}
|
||||
personalSpace = &sprovider.StorageSpace{
|
||||
Opaque: &typesv1beta1.Opaque{
|
||||
Map: map[string]*typesv1beta1.OpaqueEntry{
|
||||
"path": {
|
||||
Decoder: "plain",
|
||||
Value: []byte("/foo"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Id: &sprovider.StorageSpaceId{OpaqueId: "storageid$personalspace!personalspace"},
|
||||
Root: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "personalspace", OpaqueId: "personalspace"},
|
||||
Name: "personalspace",
|
||||
SpaceType: "personal",
|
||||
}
|
||||
|
||||
ri = &sprovider.ResourceInfo{
|
||||
Id: &sprovider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
OpaqueId: "opaqueid",
|
||||
},
|
||||
ParentId: &sprovider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
OpaqueId: "parentopaqueid",
|
||||
},
|
||||
Path: "foo.pdf",
|
||||
Size: 12345,
|
||||
Mtime: &typesv1beta1.Timestamp{Seconds: 4000},
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
|
||||
gatewayClient = &cs3mocks.GatewayAPIClient{}
|
||||
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
|
||||
"GatewaySelector",
|
||||
"qsfera.api.gateway",
|
||||
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
|
||||
return gatewayClient
|
||||
},
|
||||
)
|
||||
|
||||
ctx = revactx.ContextSetUser(context.Background(), user)
|
||||
indexClient = &engineMocks.Engine{}
|
||||
extractor = &contentMocks.Extractor{}
|
||||
|
||||
s = search.NewService(gatewaySelector, indexClient, extractor, nil, logger, &config.Config{})
|
||||
|
||||
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Token: "authtoken",
|
||||
}, nil)
|
||||
gatewayClient.On("GetPath", mock.Anything, mock.MatchedBy(func(req *sprovider.GetPathRequest) bool {
|
||||
return req.ResourceId.OpaqueId == ri.Id.OpaqueId
|
||||
})).Return(&sprovider.GetPathResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
Path: ri.Path,
|
||||
}, nil)
|
||||
gatewayClient.On("GetReceivedShare", mock.Anything, mock.Anything).Return(&collaborationv1beta1.GetReceivedShareResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
}, nil)
|
||||
indexClient.On("DocCount").Return(uint64(1), nil)
|
||||
})
|
||||
|
||||
Describe("New", func() {
|
||||
It("returns a new instance", func() {
|
||||
s := search.NewService(gatewaySelector, indexClient, extractor, nil, logger, &config.Config{})
|
||||
Expect(s).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IndexSpace", func() {
|
||||
It("walks the space and indexes all files", func() {
|
||||
batch := &engineMocks.BatchOperator{}
|
||||
batch.EXPECT().Push().Return(nil)
|
||||
gatewayClient.On("GetUserByClaim", mock.Anything, mock.Anything).Return(&userv1beta1.GetUserByClaimResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
User: user,
|
||||
}, nil)
|
||||
extractor.On("Extract", mock.Anything, mock.Anything, mock.Anything).Return(content.Document{}, nil)
|
||||
indexClient.On("NewBatch", mock.Anything).Return(batch, nil)
|
||||
batch.On("Upsert", mock.Anything, mock.Anything).Return(nil)
|
||||
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{}, nil)
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
Info: ri,
|
||||
}, nil)
|
||||
|
||||
err := s.IndexSpace(&sprovider.StorageSpaceId{OpaqueId: "storageid$spaceid!spaceid"}, false)
|
||||
Expect(err).ShouldNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Search", func() {
|
||||
It("fails when an empty query is given", func() {
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "",
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(res).To(BeNil())
|
||||
})
|
||||
|
||||
Context("with a personal space", func() {
|
||||
BeforeEach(func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*sprovider.StorageSpace{personalSpace},
|
||||
}, nil)
|
||||
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
|
||||
TotalMatches: 1,
|
||||
Matches: []*searchmsg.Match{
|
||||
{
|
||||
Score: 1,
|
||||
Entity: &searchmsg.Entity{
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: personalSpace.Root.StorageId,
|
||||
SpaceId: personalSpace.Root.SpaceId,
|
||||
OpaqueId: personalSpace.Root.OpaqueId,
|
||||
},
|
||||
Path: "./path/to/Foo.pdf",
|
||||
},
|
||||
Id: &searchmsg.ResourceID{
|
||||
StorageId: personalSpace.Root.StorageId,
|
||||
OpaqueId: "foo-id",
|
||||
},
|
||||
Name: "Foo.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
Info: ri,
|
||||
}, nil)
|
||||
})
|
||||
|
||||
It("does not mess with field-based searches", func() {
|
||||
_, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "Size:<10",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
indexClient.AssertCalled(GinkgoT(), "Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
|
||||
return req.Query == "Size:<10"
|
||||
}))
|
||||
})
|
||||
|
||||
It("searches the personal user space", func() {
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "foo",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(BeNil())
|
||||
Expect(res.TotalMatches).To(Equal(int32(1)))
|
||||
Expect(len(res.Matches)).To(Equal(1))
|
||||
match := res.Matches[0]
|
||||
Expect(match.Entity.Id.OpaqueId).To(Equal("foo-id"))
|
||||
Expect(match.Entity.Name).To(Equal("Foo.pdf"))
|
||||
Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(personalSpace.Root.OpaqueId))
|
||||
Expect(match.Entity.Ref.Path).To(Equal("./path/to/Foo.pdf"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with a personal space with a filter", func() {
|
||||
BeforeEach(func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*sprovider.StorageSpace{personalSpace},
|
||||
}, nil)
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
Info: &sprovider.ResourceInfo{
|
||||
Space: &sprovider.StorageSpace{Root: &sprovider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "personalspace",
|
||||
OpaqueId: "personalspace",
|
||||
}},
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("GetPath", mock.Anything, mock.Anything).Return(&sprovider.GetPathResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Path: "/path",
|
||||
}, nil)
|
||||
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
|
||||
TotalMatches: 1,
|
||||
Matches: []*searchmsg.Match{
|
||||
{
|
||||
Score: 1,
|
||||
Entity: &searchmsg.Entity{
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: personalSpace.Root.StorageId,
|
||||
SpaceId: personalSpace.Root.SpaceId,
|
||||
OpaqueId: personalSpace.Root.OpaqueId,
|
||||
},
|
||||
Path: "./path/to/Foo.pdf",
|
||||
},
|
||||
Id: &searchmsg.ResourceID{
|
||||
StorageId: personalSpace.Root.StorageId,
|
||||
OpaqueId: "foo-id",
|
||||
},
|
||||
Name: "Foo.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
|
||||
It("searches the personal user space", func() {
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "foo scope:storageid$personalspace!personalspace/path",
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "personalspace",
|
||||
OpaqueId: "personalspace",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(BeNil())
|
||||
Expect(res.TotalMatches).To(Equal(int32(1)))
|
||||
Expect(len(res.Matches)).To(Equal(1))
|
||||
match := res.Matches[0]
|
||||
Expect(match.Entity.Id.OpaqueId).To(Equal("foo-id"))
|
||||
Expect(match.Entity.Name).To(Equal("Foo.pdf"))
|
||||
Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(personalSpace.Root.OpaqueId))
|
||||
Expect(match.Entity.Ref.Path).To(Equal("./path/to/Foo.pdf"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with received shares", func() {
|
||||
var (
|
||||
grantSpace *sprovider.StorageSpace
|
||||
mountpointSpace *sprovider.StorageSpace
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
grantSpace = &sprovider.StorageSpace{
|
||||
SpaceType: "grant",
|
||||
Owner: otherUser,
|
||||
Id: &sprovider.StorageSpaceId{OpaqueId: "storageproviderid$spaceid!otherspacegrant"},
|
||||
Root: &sprovider.ResourceId{StorageId: "storageproviderid", SpaceId: "spaceid", OpaqueId: "otherspacegrant"},
|
||||
Name: "grantspace",
|
||||
RootInfo: &sprovider.ResourceInfo{
|
||||
Id: &sprovider.ResourceId{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "spaceid",
|
||||
OpaqueId: "opaqueid",
|
||||
},
|
||||
},
|
||||
}
|
||||
mountpointSpace = &sprovider.StorageSpace{
|
||||
SpaceType: "mountpoint",
|
||||
Owner: otherUser,
|
||||
Id: &sprovider.StorageSpaceId{OpaqueId: "storageproviderid$spaceid!otherspacemountpoint"},
|
||||
Root: &sprovider.ResourceId{StorageId: "storageproviderid", SpaceId: "spaceid", OpaqueId: "otherspacemountpoint"},
|
||||
Name: "mountpointspace",
|
||||
Opaque: &typesv1beta1.Opaque{
|
||||
Map: map[string]*typesv1beta1.OpaqueEntry{
|
||||
"grantStorageID": {Decoder: "plain", Value: []byte("storageproviderid")},
|
||||
"grantSpaceID": {Decoder: "plain", Value: []byte("spaceid")},
|
||||
"grantOpaqueID": {Decoder: "plain", Value: []byte("otherspacegrant")},
|
||||
},
|
||||
},
|
||||
}
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
Info: ri,
|
||||
}, nil)
|
||||
gatewayClient.On("GetPath", mock.Anything, mock.Anything).Return(&sprovider.GetPathResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Path: "/grant/path",
|
||||
}, nil)
|
||||
})
|
||||
|
||||
It("searches the received spaces", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*sprovider.StorageSpace{grantSpace, mountpointSpace},
|
||||
}, nil)
|
||||
indexClient.On("Search", mock.Anything, mock.Anything).Return(&searchsvc.SearchIndexResponse{
|
||||
TotalMatches: 1,
|
||||
Matches: []*searchmsg.Match{
|
||||
{
|
||||
Entity: &searchmsg.Entity{
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: grantSpace.Root.StorageId,
|
||||
SpaceId: grantSpace.Root.SpaceId,
|
||||
OpaqueId: grantSpace.Root.OpaqueId,
|
||||
},
|
||||
Path: "./grant/path/to/Shared.pdf",
|
||||
},
|
||||
Id: &searchmsg.ResourceID{
|
||||
StorageId: grantSpace.Root.StorageId,
|
||||
OpaqueId: "grant-shared-id",
|
||||
},
|
||||
Name: "Shared.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "Foo",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(BeNil())
|
||||
Expect(len(res.Matches)).To(Equal(1))
|
||||
match := res.Matches[0]
|
||||
Expect(match.Entity.Id.OpaqueId).To(Equal("grant-shared-id"))
|
||||
Expect(match.Entity.Name).To(Equal("Shared.pdf"))
|
||||
Expect(match.Entity.Ref.ResourceId.OpaqueId).To(Equal(mountpointSpace.Root.OpaqueId))
|
||||
Expect(match.Entity.Ref.Path).To(Equal("./to/Shared.pdf"))
|
||||
Expect(match.Entity.RemoteItemId).To(Equal(&searchmsg.ResourceID{
|
||||
StorageId: grantSpace.RootInfo.Id.StorageId,
|
||||
SpaceId: grantSpace.RootInfo.Id.SpaceId,
|
||||
OpaqueId: grantSpace.RootInfo.Id.OpaqueId,
|
||||
}))
|
||||
})
|
||||
|
||||
Context("when searching both spaces", func() {
|
||||
BeforeEach(func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&sprovider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*sprovider.StorageSpace{personalSpace, grantSpace, mountpointSpace},
|
||||
}, nil)
|
||||
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
|
||||
return req.Ref.ResourceId.OpaqueId == grantSpace.Root.SpaceId &&
|
||||
req.Ref.ResourceId.SpaceId == grantSpace.Root.SpaceId
|
||||
})).Return(&searchsvc.SearchIndexResponse{
|
||||
TotalMatches: 2,
|
||||
Matches: []*searchmsg.Match{
|
||||
{
|
||||
Score: 2,
|
||||
Entity: &searchmsg.Entity{
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: grantSpace.Root.StorageId,
|
||||
SpaceId: grantSpace.Root.SpaceId,
|
||||
OpaqueId: grantSpace.Root.OpaqueId,
|
||||
},
|
||||
Path: "./grant/path/to/Shared.pdf",
|
||||
},
|
||||
Id: &searchmsg.ResourceID{
|
||||
StorageId: grantSpace.Root.StorageId,
|
||||
OpaqueId: "grant-shared-id",
|
||||
},
|
||||
Name: "Shared.pdf",
|
||||
},
|
||||
},
|
||||
{
|
||||
Score: 0.01,
|
||||
Entity: &searchmsg.Entity{
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: grantSpace.Root.StorageId,
|
||||
SpaceId: grantSpace.Root.SpaceId,
|
||||
OpaqueId: grantSpace.Root.OpaqueId,
|
||||
},
|
||||
Path: "./grant/path/to/Irrelevant.pdf",
|
||||
},
|
||||
Id: &searchmsg.ResourceID{
|
||||
StorageId: grantSpace.Root.StorageId,
|
||||
OpaqueId: "grant-irrelevant-id",
|
||||
},
|
||||
Name: "Irrelevant.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
indexClient.On("Search", mock.Anything, mock.MatchedBy(func(req *searchsvc.SearchIndexRequest) bool {
|
||||
return req.Ref.ResourceId.OpaqueId == personalSpace.Root.OpaqueId &&
|
||||
req.Ref.ResourceId.SpaceId == personalSpace.Root.SpaceId
|
||||
})).Return(&searchsvc.SearchIndexResponse{
|
||||
TotalMatches: 1,
|
||||
Matches: []*searchmsg.Match{
|
||||
{
|
||||
Score: 1,
|
||||
Entity: &searchmsg.Entity{
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: personalSpace.Root.StorageId,
|
||||
SpaceId: personalSpace.Root.SpaceId,
|
||||
OpaqueId: personalSpace.Root.OpaqueId,
|
||||
},
|
||||
Path: "./path/to/Foo.pdf",
|
||||
},
|
||||
Id: &searchmsg.ResourceID{
|
||||
StorageId: personalSpace.Root.StorageId,
|
||||
OpaqueId: "foo-id",
|
||||
},
|
||||
Name: "Foo.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
|
||||
It("considers the search Ref parameter", func() {
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "foo",
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: "storageid",
|
||||
SpaceId: "personalspace",
|
||||
OpaqueId: "personalspace",
|
||||
},
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(BeNil())
|
||||
Expect(len(res.Matches)).To(Equal(1))
|
||||
Expect(res.Matches[0].Entity.Id.OpaqueId).To(Equal("foo-id"))
|
||||
})
|
||||
|
||||
It("finds matches in both the personal space AND the grant", func() {
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "foo",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(BeNil())
|
||||
Expect(len(res.Matches)).To(Equal(3))
|
||||
ids := []string{res.Matches[0].Entity.Id.OpaqueId, res.Matches[1].Entity.Id.OpaqueId, res.Matches[2].Entity.Id.OpaqueId}
|
||||
Expect(ids).To(ConsistOf("foo-id", "grant-shared-id", "grant-irrelevant-id"))
|
||||
})
|
||||
|
||||
It("sorts and limits the combined results from all spaces", func() {
|
||||
res, err := s.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: "foo",
|
||||
PageSize: 2,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(BeNil())
|
||||
Expect(len(res.Matches)).To(Equal(2))
|
||||
ids := []string{res.Matches[0].Entity.Id.OpaqueId, res.Matches[1].Entity.Id.OpaqueId}
|
||||
Expect(ids).To(Equal([]string{"grant-shared-id", "foo-id"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = DescribeTable("Parse Scope",
|
||||
func(pattern, wantSearch, wantScope string) {
|
||||
gotSearch, gotScope := search.ParseScope(pattern)
|
||||
Expect(gotSearch).To(Equal(wantSearch))
|
||||
Expect(gotScope).To(Equal(wantScope))
|
||||
},
|
||||
Entry("When scope is at the end of the line",
|
||||
`+Name:*file* +Tags:"foo" scope:<uuid>/folder/subfolder`,
|
||||
`+Name:*file* +Tags:"foo"`,
|
||||
`<uuid>/folder/subfolder`,
|
||||
),
|
||||
Entry("When scope is at the end of the line 2",
|
||||
`+Name:*file* +Tags:"foo" scope:<uuid>/folder`,
|
||||
`+Name:*file* +Tags:"foo"`,
|
||||
`<uuid>/folder`,
|
||||
),
|
||||
Entry("When scope is at the end of the line 3",
|
||||
`file scope:<uuid>/folder/subfolder`,
|
||||
`file`,
|
||||
`<uuid>/folder/subfolder`,
|
||||
),
|
||||
Entry("When scope is at the end of the line with a space",
|
||||
`+Name:*file* +Tags:"foo" scope: <uuid>/folder/subfolder`,
|
||||
`+Name:*file* +Tags:"foo"`,
|
||||
`<uuid>/folder/subfolder`,
|
||||
),
|
||||
Entry("When scope is in the middle of the line",
|
||||
`+Name:*file* scope:<uuid>/folder/subfolder +Tags:"foo"`,
|
||||
`+Name:*file* +Tags:"foo"`,
|
||||
`<uuid>/folder/subfolder`,
|
||||
),
|
||||
Entry("When scope is at the end of the line",
|
||||
`scope:<uuid>/folder/subfolder +Name:*file*`,
|
||||
`+Name:*file*`,
|
||||
`<uuid>/folder/subfolder`,
|
||||
),
|
||||
Entry("When scope is at the begging of the line",
|
||||
`scope:<uuid>/folder/subfolder file`,
|
||||
`file`,
|
||||
`<uuid>/folder/subfolder`,
|
||||
),
|
||||
Entry("When no scope",
|
||||
`+Name:*file* +Tags:"foo"`,
|
||||
`+Name:*file* +Tags:"foo"`,
|
||||
``,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/qsfera/server/pkg/checks"
|
||||
"github.com/qsfera/server/pkg/handlers"
|
||||
"github.com/qsfera/server/pkg/service/debug"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
|
||||
WithLogger(options.Logger).
|
||||
WithCheck("grpc reachability", checks.NewGRPCCheck(options.Config.GRPC.Addr))
|
||||
|
||||
readyHandlerConfiguration := healthHandlerConfiguration.
|
||||
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint)).
|
||||
WithCheck("tika-check", func(ctx context.Context) error {
|
||||
if options.Config.Extractor.Type == "tika" {
|
||||
u, err := url.Parse(options.Config.Extractor.Tika.TikaURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checks.NewTCPCheck(u.Host)(ctx)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)),
|
||||
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/metrics"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
svc "github.com/qsfera/server/services/search/pkg/service/grpc/v0"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Name string
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Metrics *metrics.Metrics
|
||||
Handler *svc.Service
|
||||
JWTSecret string
|
||||
TraceProvider trace.TracerProvider
|
||||
GatewaySelector *pool.Selector[gateway.GatewayAPIClient]
|
||||
Searcher search.Searcher
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Name provides a name for the service.
|
||||
func Name(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.Name = val
|
||||
}
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
|
||||
// Handler provides a function to set the handler option.
|
||||
func Handler(val *svc.Service) Option {
|
||||
return func(o *Options) {
|
||||
o.Handler = val
|
||||
}
|
||||
}
|
||||
|
||||
// JWTSecret provides a function to set the Config option.
|
||||
func JWTSecret(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.JWTSecret = val
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the trace provider option.
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// GatewaySelector provides a function to set the GatewaySelector option.
|
||||
func GatewaySelector(val *pool.Selector[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = val
|
||||
}
|
||||
}
|
||||
|
||||
// Searcher provides a function to set the Searcher option.
|
||||
func Searcher(val search.Searcher) Option {
|
||||
return func(o *Options) {
|
||||
o.Searcher = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"github.com/qsfera/server/pkg/service/grpc"
|
||||
"github.com/qsfera/server/pkg/version"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
svc "github.com/qsfera/server/services/search/pkg/service/grpc/v0"
|
||||
)
|
||||
|
||||
// Server initializes a new go-micro service ready to run
|
||||
func Server(opts ...Option) (grpc.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
service, err := grpc.NewServiceWithClient(
|
||||
options.Config.GrpcClient,
|
||||
grpc.TLSEnabled(options.Config.GRPC.TLS.Enabled),
|
||||
grpc.TLSCert(
|
||||
options.Config.GRPC.TLS.Cert,
|
||||
options.Config.GRPC.TLS.Key,
|
||||
),
|
||||
grpc.Name(options.Config.Service.Name),
|
||||
grpc.Context(options.Context),
|
||||
grpc.Address(options.Config.GRPC.Addr),
|
||||
grpc.Namespace(options.Config.GRPC.Namespace),
|
||||
grpc.Logger(options.Logger),
|
||||
grpc.Version(version.GetString()),
|
||||
grpc.TraceProvider(options.TraceProvider),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Fatal().Err(err).Msg("Error creating search service")
|
||||
return grpc.Service{}, err
|
||||
}
|
||||
|
||||
handle, err := svc.NewHandler(
|
||||
svc.Config(options.Config),
|
||||
svc.Logger(options.Logger),
|
||||
svc.JWTSecret(options.JWTSecret),
|
||||
svc.TracerProvider(options.TraceProvider),
|
||||
svc.Metrics(options.Metrics),
|
||||
svc.GatewaySelector(options.GatewaySelector),
|
||||
svc.Searcher(options.Searcher),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing search service")
|
||||
return grpc.Service{}, err
|
||||
}
|
||||
|
||||
if err := searchsvc.RegisterSearchProviderHandler(
|
||||
service.Server(),
|
||||
handle,
|
||||
); err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error registering search provider handler")
|
||||
return grpc.Service{}, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
)
|
||||
|
||||
// SpaceDebouncer debounces operations on spaces for a configurable amount of time
|
||||
type SpaceDebouncer struct {
|
||||
after time.Duration
|
||||
timeout time.Duration
|
||||
f func(id *provider.StorageSpaceId)
|
||||
pending map[string]*workItem
|
||||
inProgress sync.Map
|
||||
|
||||
mutex sync.Mutex
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
type workItem struct {
|
||||
t *time.Timer
|
||||
timeout *time.Timer
|
||||
|
||||
work func()
|
||||
}
|
||||
|
||||
type AckFunc func() error
|
||||
|
||||
// NewSpaceDebouncer returns a new SpaceDebouncer instance
|
||||
func NewSpaceDebouncer(d time.Duration, timeout time.Duration, f func(id *provider.StorageSpaceId), logger log.Logger) *SpaceDebouncer {
|
||||
return &SpaceDebouncer{
|
||||
after: d,
|
||||
timeout: timeout,
|
||||
f: f,
|
||||
pending: map[string]*workItem{},
|
||||
inProgress: sync.Map{},
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce restars the debounce timer for the given space
|
||||
func (d *SpaceDebouncer) Debounce(id *provider.StorageSpaceId, ack AckFunc) {
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
if wi := d.pending[id.OpaqueId]; wi != nil {
|
||||
if ack != nil {
|
||||
go ack() // Acknowledge the event immediately, the according space is already scheduled for indexing
|
||||
}
|
||||
wi.t.Reset(d.after)
|
||||
return
|
||||
}
|
||||
|
||||
wi := &workItem{}
|
||||
wi.work = func() {
|
||||
if _, ok := d.inProgress.Load(id.OpaqueId); ok {
|
||||
// Reschedule this run for when the previous run has finished
|
||||
d.mutex.Lock()
|
||||
if wi := d.pending[id.OpaqueId]; wi != nil {
|
||||
wi.t.Reset(d.after)
|
||||
}
|
||||
d.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.mutex.Lock()
|
||||
wi.timeout.Stop() // stop the timeout timer if it is running
|
||||
delete(d.pending, id.OpaqueId)
|
||||
d.inProgress.Store(id.OpaqueId, true)
|
||||
defer func() {
|
||||
d.inProgress.Delete(id.OpaqueId)
|
||||
}()
|
||||
d.mutex.Unlock() // release the lock early to allow other goroutines to debounce
|
||||
|
||||
d.f(id)
|
||||
go func() {
|
||||
if ack != nil {
|
||||
if err := ack(); err != nil {
|
||||
d.log.Error().Err(err).Msg("error while acknowledging event")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wi.t = time.AfterFunc(d.after, wi.work)
|
||||
wi.timeout = time.AfterFunc(d.timeout, func() {
|
||||
d.log.Debug().Msg("timeout while waiting for space debouncer to finish")
|
||||
wi.t.Stop()
|
||||
wi.work()
|
||||
})
|
||||
|
||||
d.pending[id.OpaqueId] = wi
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
sprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/service/event"
|
||||
)
|
||||
|
||||
var _ = Describe("SpaceDebouncer", func() {
|
||||
var (
|
||||
debouncer *event.SpaceDebouncer
|
||||
|
||||
callCount atomic.Int32
|
||||
|
||||
spaceid = &sprovider.StorageSpaceId{
|
||||
OpaqueId: "spaceid",
|
||||
}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
callCount = atomic.Int32{}
|
||||
debouncer = event.NewSpaceDebouncer(50*time.Millisecond, 10*time.Second, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
}, log.NewLogger())
|
||||
})
|
||||
|
||||
It("debounces", func() {
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
})
|
||||
|
||||
It("works multiple times", func() {
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(2))
|
||||
})
|
||||
|
||||
It("doesn't trigger twice simultaneously", func() {
|
||||
debouncer = event.NewSpaceDebouncer(50*time.Millisecond, 5*time.Second, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}, log.NewLogger())
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
time.Sleep(100 * time.Millisecond) // Let it trigger once
|
||||
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
time.Sleep(100 * time.Millisecond) // shouldn't trigger as the other run is still in progress
|
||||
Expect(int(callCount.Load())).To(Equal(1))
|
||||
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "2000ms").Should(Equal(2))
|
||||
})
|
||||
|
||||
It("fires at the timeout even when continuously debounced", func() {
|
||||
debouncer = event.NewSpaceDebouncer(100*time.Millisecond, 250*time.Millisecond, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
}, log.NewLogger())
|
||||
|
||||
// Reset the debounce timer every 50ms (shorter than the 100ms debounce
|
||||
// duration) but stop before the 250ms timeout fires. Continuing past the
|
||||
// timeout would race with the work function's cleanup of pending state:
|
||||
// a Debounce call arriving right after the timeout fires would find
|
||||
// pending empty and schedule a second workItem, breaking the assertion
|
||||
// below that the work function is invoked exactly once.
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
for i := 0; i < 4 && callCount.Load() == 0; i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if callCount.Load() == 0 {
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// The debounce timer (100ms) was reset roughly every 50ms and should
|
||||
// not have fired. The timeout timer (250ms) should fire regardless.
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "300ms").Should(Equal(1))
|
||||
|
||||
// And it should not fire again
|
||||
Consistently(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "300ms").Should(Equal(1))
|
||||
})
|
||||
|
||||
It("doesn't run the timeout function if the work function has been called", func() {
|
||||
debouncer = event.NewSpaceDebouncer(100*time.Millisecond, 250*time.Millisecond, func(id *sprovider.StorageSpaceId) {
|
||||
if id.OpaqueId == "spaceid" {
|
||||
callCount.Add(1)
|
||||
}
|
||||
}, log.NewLogger())
|
||||
|
||||
// Initial call to start the timers
|
||||
debouncer.Debounce(spaceid, nil)
|
||||
|
||||
// Wait for the debounce timer to fire
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
|
||||
// The timeout function should not be called
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
Expect(int(callCount.Load())).To(Equal(1))
|
||||
})
|
||||
|
||||
It("calls the ack function when the debounce fires", func() {
|
||||
var ackCalled atomic.Bool
|
||||
ackFunc := func() error {
|
||||
ackCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
debouncer.Debounce(spaceid, ackFunc)
|
||||
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
Eventually(func() bool {
|
||||
return ackCalled.Load()
|
||||
}, "200ms").Should(BeTrue())
|
||||
})
|
||||
|
||||
It("calls the ack function immediately for subsequent calls", func() {
|
||||
var firstAckCalled atomic.Bool
|
||||
firstAckFunc := func() error {
|
||||
firstAckCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
var secondAckCalled atomic.Bool
|
||||
secondAckFunc := func() error {
|
||||
secondAckCalled.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// First call, sets up the trigger
|
||||
debouncer.Debounce(spaceid, firstAckFunc)
|
||||
Expect(firstAckCalled.Load()).To(BeFalse())
|
||||
Expect(secondAckCalled.Load()).To(BeFalse())
|
||||
|
||||
// Second call, should call its ack immediately
|
||||
debouncer.Debounce(spaceid, secondAckFunc)
|
||||
Eventually(func() bool {
|
||||
return secondAckCalled.Load()
|
||||
}, "50ms").Should(BeTrue())
|
||||
// The first ack is not yet called.
|
||||
Expect(firstAckCalled.Load()).To(BeFalse())
|
||||
|
||||
// After the debounce period, the trigger fires, calling the main function and the first ack.
|
||||
Eventually(func() int {
|
||||
return int(callCount.Load())
|
||||
}, "200ms").Should(Equal(1))
|
||||
Eventually(func() bool {
|
||||
return firstAckCalled.Load()
|
||||
}, "200ms").Should(BeTrue())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/qsfera/server/pkg/registry"
|
||||
mRegistry "go-micro.dev/v4/registry"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func init() {
|
||||
r := registry.GetRegistry(registry.Inmemory())
|
||||
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
|
||||
service.Nodes = []*mRegistry.Node{{
|
||||
Address: "any",
|
||||
}}
|
||||
|
||||
_ = r.Register(service)
|
||||
}
|
||||
func TestEvent(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Event Suite")
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/metrics"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var tracer trace.Tracer
|
||||
|
||||
func init() {
|
||||
tracer = otel.Tracer("github.com/qsfera/server/services/search/pkg/service/event")
|
||||
}
|
||||
|
||||
// Service defines the service handlers.
|
||||
type Service struct {
|
||||
ctx context.Context
|
||||
log log.Logger
|
||||
tp trace.TracerProvider
|
||||
m *metrics.Metrics
|
||||
index search.Searcher
|
||||
events []events.Unmarshaller
|
||||
stream raw.Stream
|
||||
indexSpaceDebouncer *SpaceDebouncer
|
||||
numConsumers int
|
||||
stopCh chan struct{}
|
||||
stopped *atomic.Bool
|
||||
}
|
||||
|
||||
// New returns a service implementation for Service.
|
||||
func New(ctx context.Context, stream raw.Stream, logger log.Logger, tp trace.TracerProvider, m *metrics.Metrics, index search.Searcher, debounceDuration int, numConsumers int, asyncUploads bool) (Service, error) {
|
||||
svc := Service{
|
||||
ctx: ctx,
|
||||
log: logger,
|
||||
tp: tp,
|
||||
m: m,
|
||||
index: index,
|
||||
stream: stream,
|
||||
stopCh: make(chan struct{}, 1),
|
||||
stopped: new(atomic.Bool),
|
||||
events: []events.Unmarshaller{
|
||||
events.ItemTrashed{},
|
||||
events.ItemPurged{},
|
||||
events.ItemRestored{},
|
||||
events.ItemMoved{},
|
||||
events.TrashbinPurged{},
|
||||
events.ContainerCreated{},
|
||||
events.FileTouched{},
|
||||
events.FileVersionRestored{},
|
||||
events.TagsAdded{},
|
||||
events.TagsRemoved{},
|
||||
events.SpaceRenamed{},
|
||||
events.LabelAdded{},
|
||||
events.LabelRemoved{},
|
||||
},
|
||||
numConsumers: numConsumers,
|
||||
}
|
||||
|
||||
if asyncUploads {
|
||||
svc.events = append(svc.events, events.UploadReady{})
|
||||
} else {
|
||||
svc.events = append(svc.events, events.FileUploaded{})
|
||||
}
|
||||
|
||||
svc.indexSpaceDebouncer = NewSpaceDebouncer(time.Duration(debounceDuration)*time.Millisecond, 30*time.Second, func(id *provider.StorageSpaceId) {
|
||||
if err := svc.index.IndexSpace(id, false); err != nil {
|
||||
svc.log.Error().Err(err).Interface("spaceID", id).Msg("error while indexing a space")
|
||||
}
|
||||
}, svc.log)
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Run to fulfil Runner interface
|
||||
func (s Service) Run() error {
|
||||
ch, err := s.stream.Consume("search-pull", s.events...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.m != nil {
|
||||
monitorMetrics(s.ctx, s.stream, "search-pull", s.m, s.log)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
defer cancel()
|
||||
|
||||
s.log.Debug().Int("worker.count", s.numConsumers).
|
||||
Str("messaging.consumer.group.name", "search-pull").
|
||||
Str("messaging.system", "nats").
|
||||
Str("messaging.operation.name", "receive").
|
||||
Msg("starting event processing workers")
|
||||
|
||||
// start workers
|
||||
for i := 0; i < s.numConsumers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case e, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.processEvent(e); err != nil {
|
||||
s.log.Error().Err(err).
|
||||
Int("worker", workerID).
|
||||
Interface("event", e).
|
||||
Msg("failed to process event")
|
||||
}
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// wait for stop signal
|
||||
<-s.stopCh
|
||||
cancel() // signal workers to stop
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close will make the service to stop processing, so the `Run`
|
||||
// method can finish.
|
||||
// TODO: Underlying services can't be stopped. This means that some goroutines
|
||||
// will get stuck trying to push events through a channel nobody is reading
|
||||
// from, so resources won't be freed and there will be memory leaks. For now,
|
||||
// if the service is stopped, you should close the app soon after.
|
||||
func (s Service) Close() {
|
||||
if s.stopped.CompareAndSwap(false, true) {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func getSpaceID(ref *provider.Reference) *provider.StorageSpaceId {
|
||||
return &provider.StorageSpaceId{
|
||||
OpaqueId: storagespace.FormatResourceID(
|
||||
&provider.ResourceId{
|
||||
StorageId: ref.GetResourceId().GetStorageId(),
|
||||
SpaceId: ref.GetResourceId().GetSpaceId(),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (s Service) processEvent(e raw.Event) error {
|
||||
ctx := e.GetTraceContext(s.ctx)
|
||||
_, span := tracer.Start(ctx, "processEvent")
|
||||
defer span.End()
|
||||
|
||||
e.InProgress() // let nats know that we are processing this event
|
||||
s.log.Debug().Interface("event", e).Msg("updating index")
|
||||
|
||||
switch ev := e.Event.Event.(type) {
|
||||
case events.ItemTrashed:
|
||||
s.index.TrashItem(ev.ID)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.ItemPurged:
|
||||
s.index.PurgeItem(ev.Ref)
|
||||
e.Ack()
|
||||
case events.TrashbinPurged:
|
||||
s.index.PurgeDeleted(getSpaceID(ev.Ref))
|
||||
e.Ack()
|
||||
case events.ItemMoved:
|
||||
s.index.MoveItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.ItemRestored:
|
||||
s.index.RestoreItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.ContainerCreated:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.FileTouched:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.FileVersionRestored:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.TagsAdded:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.TagsRemoved:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.FileUploaded:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.Ref), e.Ack)
|
||||
case events.UploadReady:
|
||||
s.indexSpaceDebouncer.Debounce(getSpaceID(ev.FileRef), e.Ack)
|
||||
case events.SpaceRenamed:
|
||||
s.indexSpaceDebouncer.Debounce(ev.ID, e.Ack)
|
||||
case events.LabelAdded:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
case events.LabelRemoved:
|
||||
s.index.UpsertItem(ev.Ref)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func monitorMetrics(ctx context.Context, stream raw.Stream, name string, m *metrics.Metrics, logger log.Logger) {
|
||||
consumer, err := stream.JetStream().Consumer(ctx, name)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to get consumer")
|
||||
}
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
info, err := consumer.Info(ctx)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to get consumer")
|
||||
continue
|
||||
}
|
||||
|
||||
m.EventsOutstandingAcks.Set(float64(info.NumAckPending))
|
||||
m.EventsUnprocessed.Set(float64(info.NumPending))
|
||||
m.EventsRedelivered.Set(float64(info.NumRedelivered))
|
||||
logger.Trace().Msg("updated search event metrics")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
searchMocks "github.com/qsfera/server/services/search/pkg/search/mocks"
|
||||
"github.com/qsfera/server/services/search/pkg/service/event"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events/raw"
|
||||
rawMocks "github.com/opencloud-eu/reva/v2/pkg/events/raw/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = DescribeTable("event",
|
||||
func(mcks []string, e any, asyncUploads bool) {
|
||||
var (
|
||||
s = &searchMocks.Searcher{}
|
||||
calls atomic.Int32
|
||||
)
|
||||
|
||||
stream := rawMocks.NewStream(GinkgoT())
|
||||
ch := make(chan raw.Event, 1)
|
||||
stream.EXPECT().Consume(mock.Anything, mock.Anything).Return((<-chan raw.Event)(ch), nil)
|
||||
|
||||
event, err := event.New(context.Background(), stream, log.NewLogger(), nil, nil, s, 50, 1, asyncUploads)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
go func() {
|
||||
err := event.Run()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
}()
|
||||
defer event.Close()
|
||||
|
||||
for _, mck := range mcks {
|
||||
s.On(mck, mock.Anything, mock.Anything).Return(nil).Run(func(args mock.Arguments) {
|
||||
calls.Add(1)
|
||||
})
|
||||
}
|
||||
|
||||
ch <- raw.Event{
|
||||
Event: events.Event{Event: e},
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
return int(calls.Load())
|
||||
}, "2s").Should(Equal(len(mcks)))
|
||||
},
|
||||
Entry("ItemTrashed", []string{"TrashItem", "IndexSpace"}, events.ItemTrashed{}, false),
|
||||
Entry("ItemMoved", []string{"MoveItem", "IndexSpace"}, events.ItemMoved{}, false),
|
||||
Entry("ItemRestored", []string{"RestoreItem", "IndexSpace"}, events.ItemRestored{}, false),
|
||||
Entry("ContainerCreated", []string{"IndexSpace"}, events.ContainerCreated{}, false),
|
||||
Entry("FileTouched", []string{"IndexSpace"}, events.FileTouched{}, false),
|
||||
Entry("FileVersionRestored", []string{"IndexSpace"}, events.FileVersionRestored{}, false),
|
||||
Entry("TagsAdded", []string{"UpsertItem", "IndexSpace"}, events.TagsAdded{}, false),
|
||||
Entry("TagsRemoved", []string{"UpsertItem", "IndexSpace"}, events.TagsRemoved{}, false),
|
||||
Entry("FileUploaded", []string{"IndexSpace"}, events.FileUploaded{}, false),
|
||||
Entry("UploadReady", []string{"IndexSpace"}, events.UploadReady{ExecutingUser: &userv1beta1.User{}}, true),
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/metrics"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
JWTSecret string
|
||||
TracerProvider trace.TracerProvider
|
||||
Metrics *metrics.Metrics
|
||||
GatewaySelector *pool.Selector[gateway.GatewayAPIClient]
|
||||
Searcher search.Searcher
|
||||
}
|
||||
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the Logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the Config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// JWTSecret provides a function to set the Config option.
|
||||
func JWTSecret(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.JWTSecret = val
|
||||
}
|
||||
}
|
||||
|
||||
// TracerProvider provides a function to set the TracerProvider option
|
||||
func TracerProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TracerProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the Metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
if val != nil {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GatewaySelector provides a function to set the GatewaySelector option.
|
||||
func GatewaySelector(val *pool.Selector[gateway.GatewayAPIClient]) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewaySelector = val
|
||||
}
|
||||
}
|
||||
|
||||
// Searcher provides a function to set the Searcher option.
|
||||
func Searcher(val search.Searcher) Option {
|
||||
return func(o *Options) {
|
||||
o.Searcher = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/jellydator/ttlcache/v2"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
"go-micro.dev/v4/metadata"
|
||||
grpcmetadata "google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
v0 "github.com/qsfera/server/protogen/gen/qsfera/messages/search/v0"
|
||||
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
|
||||
"github.com/qsfera/server/services/search/pkg/config"
|
||||
"github.com/qsfera/server/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// NewHandler returns a service implementation for Service.
|
||||
func NewHandler(opts ...Option) (searchsvc.SearchProviderHandler, error) {
|
||||
options := newOptions(opts...)
|
||||
cfg := options.Config
|
||||
if options.GatewaySelector == nil {
|
||||
return nil, errors.New("no GatewaySelector provided")
|
||||
}
|
||||
if options.Searcher == nil {
|
||||
return nil, errors.New("no Searcher provided")
|
||||
}
|
||||
|
||||
cache := ttlcache.NewCache()
|
||||
if err := cache.SetTTL(time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenManager, err := jwt.New(map[string]any{
|
||||
"secret": options.JWTSecret,
|
||||
"expires": int64(24 * 60 * 60),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
id: cfg.GRPC.Namespace + "." + cfg.Service.Name,
|
||||
log: &options.Logger,
|
||||
searcher: options.Searcher,
|
||||
cache: cache,
|
||||
tokenManager: tokenManager,
|
||||
gws: options.GatewaySelector,
|
||||
cfg: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Service implements the searchServiceHandler interface
|
||||
type Service struct {
|
||||
id string
|
||||
log *log.Logger
|
||||
searcher search.Searcher
|
||||
cache *ttlcache.Cache
|
||||
tokenManager token.Manager
|
||||
gws *pool.Selector[gateway.GatewayAPIClient]
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// Search handles the search
|
||||
func (s Service) Search(ctx context.Context, in *searchsvc.SearchRequest, out *searchsvc.SearchResponse) error {
|
||||
// Get token from the context (go-micro) and make it known to the reva client too (grpc)
|
||||
t, ok := metadata.Get(ctx, revactx.TokenHeader)
|
||||
if !ok {
|
||||
s.log.Error().Msg("Could not get token from context")
|
||||
return errors.New("could not get token from context")
|
||||
}
|
||||
ctx = grpcmetadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t)
|
||||
|
||||
// unpack user
|
||||
u, _, err := s.tokenManager.DismantleToken(ctx, t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = revactx.ContextSetUser(ctx, u)
|
||||
|
||||
key := cacheKey(in.Query, in.PageSize, in.Ref, u)
|
||||
res, ok := s.FromCache(key)
|
||||
if !ok {
|
||||
var err error
|
||||
res, err = s.searcher.Search(ctx, &searchsvc.SearchRequest{
|
||||
Query: in.Query,
|
||||
PageSize: in.PageSize,
|
||||
Ref: in.Ref,
|
||||
})
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case errtypes.BadRequest:
|
||||
return merrors.BadRequest(s.id, "%s", err.Error())
|
||||
default:
|
||||
return merrors.InternalServerError(s.id, "%s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
s.Cache(key, res)
|
||||
}
|
||||
|
||||
out.Matches = res.Matches
|
||||
out.TotalMatches = res.TotalMatches
|
||||
out.NextPageToken = res.NextPageToken
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexSpace (re)indexes all resources of a given space.
|
||||
func (s Service) IndexSpace(_ context.Context, in *searchsvc.IndexSpaceRequest, _ *searchsvc.IndexSpaceResponse) error {
|
||||
if in.GetSpaceId() != "" {
|
||||
return s.searcher.IndexSpace(&provider.StorageSpaceId{OpaqueId: in.GetSpaceId()}, in.GetForceReindex())
|
||||
}
|
||||
|
||||
// index all spaces instead
|
||||
gwc, err := s.gws.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := utils.GetServiceUserContext(s.cfg.ServiceAccount.ServiceAccountID, gwc, s.cfg.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := gwc.ListStorageSpaces(ctx, &provider.ListStorageSpacesRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
|
||||
return errors.New(resp.GetStatus().GetMessage())
|
||||
}
|
||||
|
||||
for _, space := range resp.GetStorageSpaces() {
|
||||
if err := s.searcher.IndexSpace(space.GetId(), in.GetForceReindex()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FromCache pulls a search result from cache
|
||||
func (s Service) FromCache(key string) (*searchsvc.SearchResponse, bool) {
|
||||
v, err := s.cache.Get(key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sr, ok := v.(*searchsvc.SearchResponse)
|
||||
return sr, ok
|
||||
}
|
||||
|
||||
// Cache caches the search result
|
||||
func (s Service) Cache(key string, res *searchsvc.SearchResponse) {
|
||||
// lets ignore the error
|
||||
_ = s.cache.Set(key, res)
|
||||
}
|
||||
|
||||
func cacheKey(query string, pagesize int32, ref *v0.Reference, user *user.User) string {
|
||||
return fmt.Sprintf("%s|%d|%s$%s!%s/%s|%s", query, pagesize, ref.GetResourceId().GetStorageId(), ref.GetResourceId().GetSpaceId(), ref.GetResourceId().GetOpaqueId(), ref.GetPath(), user.GetId().GetOpaqueId())
|
||||
}
|
||||
Reference in New Issue
Block a user