mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-08 11:53:07 -04:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab9a224091 | ||
|
|
1de86ef559 | ||
|
|
0fb6877878 | ||
|
|
35231b1418 | ||
|
|
15149109e2 | ||
|
|
2edb893ee6 | ||
|
|
c7340ac004 | ||
|
|
6022e8fcfd | ||
|
|
b14dc31545 | ||
|
|
dc90d2dda9 | ||
|
|
fe10ad0ead | ||
|
|
5d08f16114 | ||
|
|
c6f9cc72dc | ||
|
|
a74b1c58f7 | ||
|
|
edff1e93d5 | ||
|
|
c40c46aa88 | ||
|
|
41af1d8bb8 | ||
|
|
7e59839a76 | ||
|
|
6d799d1439 | ||
|
|
c595a53818 | ||
|
|
aca8f9d2b8 | ||
|
|
8bfacb0823 | ||
|
|
dfd5a47026 | ||
|
|
a7ed42f434 | ||
|
|
e5d16157bc | ||
|
|
51bda55501 | ||
|
|
f40544de04 | ||
|
|
65712716a8 | ||
|
|
3affd156d6 | ||
|
|
4500036f33 | ||
|
|
ab6d0b56b6 | ||
|
|
3fbc04d06b | ||
|
|
c2031654fb | ||
|
|
5278fd92f3 | ||
|
|
8f53b31178 | ||
|
|
b09834f98d | ||
|
|
ac3c2b4579 | ||
|
|
d352ee53c5 | ||
|
|
f7087c973b | ||
|
|
6ce3d206c3 | ||
|
|
6fae0be65d | ||
|
|
6fa56469d1 | ||
|
|
162f6e26b8 | ||
|
|
2d3f44be43 | ||
|
|
25450554b4 | ||
|
|
ee500379f0 | ||
|
|
9dd78193c8 | ||
|
|
102f2274a3 | ||
|
|
14bcd45d4d | ||
|
|
c763d96207 | ||
|
|
e2a2915712 | ||
|
|
561a963e85 | ||
|
|
19b3a96d26 | ||
|
|
fb69bd525a |
No files matched your search
@@ -408,3 +408,5 @@ replace go-micro.dev/v4 => github.com/butonic/go-micro/v4 v4.11.1-0.202411151126
|
||||
exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
|
||||
replace github.com/go-micro/plugins/v4/store/nats-js-kv => github.com/opencloud-eu/go-micro-plugins/v4/store/nats-js-kv v0.0.0-20250512152754-23325793059a
|
||||
|
||||
replace github.com/dhowden/tag => github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973
|
||||
@@ -293,8 +293,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
@@ -306,6 +304,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973 h1:XnAwTTbblPJI6OVkRwqVA2MGjre29B/+wsi16lQfJ+E=
|
||||
github.com/dschmidt/tag v0.0.0-20260730135516-8eb6fc4a6973/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e h1:rcHHSQqzCgvlwP0I/fQ8rQMn/MpHE5gWSLdtpxtP6KQ=
|
||||
|
||||
@@ -43,6 +43,9 @@ type StringNode struct {
|
||||
*Base
|
||||
Key string
|
||||
Value string
|
||||
// CaseInsensitive marks a case-insensitive restriction; set by the search
|
||||
// lowering pass, a backend routes it to the field's lowercased form.
|
||||
CaseInsensitive bool
|
||||
}
|
||||
|
||||
// BooleanNode represents a bool value
|
||||
|
||||
+2
-3
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/jinzhu/now"
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
)
|
||||
|
||||
func toNode[T ast.Node](in any) (T, error) {
|
||||
@@ -84,7 +83,7 @@ func toTimeRange(in any) (*time.Time, *time.Time, error) {
|
||||
|
||||
value, err := toString(in)
|
||||
if err != nil {
|
||||
return &from, &to, &query.UnsupportedTimeRangeError{}
|
||||
return &from, &to, &UnsupportedTimeRangeError{}
|
||||
}
|
||||
|
||||
c := &now.Config{
|
||||
@@ -131,7 +130,7 @@ func toTimeRange(in any) (*time.Time, *time.Time, error) {
|
||||
}
|
||||
|
||||
if from.IsZero() || to.IsZero() {
|
||||
return nil, nil, &query.UnsupportedTimeRangeError{}
|
||||
return nil, nil, &UnsupportedTimeRangeError{}
|
||||
}
|
||||
|
||||
return &from, &to, nil
|
||||
|
||||
+10
-11
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast/test"
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
tAssert "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -34,13 +33,13 @@ func TestParse_Spec(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: `AND`,
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `AND cat AND dog`,
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
@@ -80,13 +79,13 @@ func TestParse_Spec(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: `OR`,
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: `OR cat AND dog`,
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
@@ -860,37 +859,37 @@ func TestParse_Errors(t *testing.T) {
|
||||
tests := []testCase{
|
||||
{
|
||||
query: "animal:(mammal:cat mammal:dog reptile:turtle)",
|
||||
error: query.NamedGroupInvalidNodesError{
|
||||
error: kql.NamedGroupInvalidNodesError{
|
||||
Node: &ast.StringNode{Key: "mammal", Value: "cat"},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "animal:(cat mammal:dog turtle)",
|
||||
error: query.NamedGroupInvalidNodesError{
|
||||
error: kql.NamedGroupInvalidNodesError{
|
||||
Node: &ast.StringNode{Key: "mammal", Value: "dog"},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "animal:(AND cat)",
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "animal:(OR cat)",
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "(AND cat)",
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: "(OR cat)",
|
||||
error: query.StartsWithBinaryOperatorError{
|
||||
error: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolOR},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package query
|
||||
package kql
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -38,6 +38,8 @@ func (e UnsupportedTimeRangeError) Error() string {
|
||||
return fmt.Sprintf("unable to convert '%v' to a time range", e.Value)
|
||||
}
|
||||
|
||||
// IsValidationError reports whether err is one of the KQL parse/validation
|
||||
// errors produced by this package.
|
||||
func IsValidationError(err error) bool {
|
||||
switch err.(type) {
|
||||
case *StartsWithBinaryOperatorError, *NamedGroupInvalidNodesError, *UnsupportedTimeRangeError:
|
||||
+1
-2
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
tAssert "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -22,7 +21,7 @@ func TestNewAST(t *testing.T) {
|
||||
{
|
||||
name: "error",
|
||||
givenQuery: kql.BoolAND,
|
||||
expectedError: query.StartsWithBinaryOperatorError{
|
||||
expectedError: kql.StartsWithBinaryOperatorError{
|
||||
Node: &ast.OperatorNode{Value: kql.BoolAND},
|
||||
},
|
||||
},
|
||||
|
||||
+3
-4
@@ -2,7 +2,6 @@ package kql
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
)
|
||||
|
||||
func validateAst(a *ast.Ast) error {
|
||||
@@ -10,7 +9,7 @@ func validateAst(a *ast.Ast) error {
|
||||
case *ast.OperatorNode:
|
||||
switch node.Value {
|
||||
case BoolAND, BoolOR:
|
||||
return &query.StartsWithBinaryOperatorError{Node: node}
|
||||
return &StartsWithBinaryOperatorError{Node: node}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -21,14 +20,14 @@ func validateGroupNode(n *ast.GroupNode) error {
|
||||
case *ast.OperatorNode:
|
||||
switch node.Value {
|
||||
case BoolAND, BoolOR:
|
||||
return &query.StartsWithBinaryOperatorError{Node: node}
|
||||
return &StartsWithBinaryOperatorError{Node: node}
|
||||
}
|
||||
}
|
||||
|
||||
if n.Key != "" {
|
||||
for _, node := range n.Nodes {
|
||||
if ast.NodeKey(node) != "" {
|
||||
return &query.NamedGroupInvalidNodesError{Node: node}
|
||||
return &NamedGroupInvalidNodesError{Node: node}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
@@ -28,6 +26,7 @@ import (
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
)
|
||||
|
||||
// CreateUploadSession create an upload session to allow your app to upload files up to the maximum file size.
|
||||
@@ -452,130 +451,20 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto
|
||||
}
|
||||
}
|
||||
|
||||
if res.GetArbitraryMetadata() != nil {
|
||||
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(logger, res)
|
||||
driveItem.Image = cs3ResourceToDriveItemImageFacet(logger, res)
|
||||
driveItem.Location = cs3ResourceToDriveItemLocationFacet(logger, res)
|
||||
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(logger, res)
|
||||
if metadata := res.GetArbitraryMetadata().GetMetadata(); metadata != nil {
|
||||
driveItem.Audio = metadataToFacet[libregraph.Audio](metadata, "audio")
|
||||
driveItem.Image = metadataToFacet[libregraph.Image](metadata, "image")
|
||||
driveItem.Location = metadataToFacet[libregraph.GeoCoordinates](metadata, "location")
|
||||
driveItem.Photo = metadataToFacet[libregraph.Photo](metadata, "photo")
|
||||
}
|
||||
|
||||
return driveItem, nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemAudioFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Audio {
|
||||
if !strings.HasPrefix(res.GetMimeType(), "audio/") {
|
||||
return nil
|
||||
}
|
||||
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var audio = &libregraph.Audio{}
|
||||
if ok := unmarshalStringMap(logger, audio, k, "libre.graph.audio."); ok {
|
||||
return audio
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemImageFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Image {
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var image = &libregraph.Image{}
|
||||
if ok := unmarshalStringMap(logger, image, k, "libre.graph.image."); ok {
|
||||
return image
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemLocationFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.GeoCoordinates {
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var location = &libregraph.GeoCoordinates{}
|
||||
if ok := unmarshalStringMap(logger, location, k, "libre.graph.location."); ok {
|
||||
return location
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItemPhotoFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Photo {
|
||||
k := res.GetArbitraryMetadata().GetMetadata()
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var photo = &libregraph.Photo{}
|
||||
if ok := unmarshalStringMap(logger, photo, k, "libre.graph.photo."); ok {
|
||||
return photo
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFieldName(structField reflect.StructField) string {
|
||||
tag := structField.Tag.Get("json")
|
||||
if tag == "" {
|
||||
return structField.Name
|
||||
}
|
||||
|
||||
return strings.Split(tag, ",")[0]
|
||||
}
|
||||
|
||||
func unmarshalStringMap(logger *log.Logger, out any, flatMap map[string]string, prefix string) bool {
|
||||
nonEmpty := false
|
||||
obj := reflect.ValueOf(out).Elem()
|
||||
timeKind := reflect.TypeOf(&time.Time{}).Elem().Kind()
|
||||
for i := 0; i < obj.NumField(); i++ {
|
||||
field := obj.Field(i)
|
||||
structField := obj.Type().Field(i)
|
||||
mapKey := prefix + getFieldName(structField)
|
||||
|
||||
if value, ok := flatMap[mapKey]; ok {
|
||||
if field.Kind() == reflect.Ptr {
|
||||
newValue := reflect.New(field.Type().Elem())
|
||||
var tmp any
|
||||
var err error
|
||||
switch t := newValue.Type().Elem().Kind(); t {
|
||||
case reflect.String:
|
||||
tmp = value
|
||||
case reflect.Int32:
|
||||
tmp, err = strconv.ParseInt(value, 10, 32)
|
||||
case reflect.Int64:
|
||||
tmp, err = strconv.ParseInt(value, 10, 64)
|
||||
case reflect.Float32:
|
||||
tmp, err = strconv.ParseFloat(value, 32)
|
||||
case reflect.Float64:
|
||||
tmp, err = strconv.ParseFloat(value, 64)
|
||||
case reflect.Bool:
|
||||
tmp, err = strconv.ParseBool(value)
|
||||
case timeKind:
|
||||
tmp, err = time.Parse(time.RFC3339, value)
|
||||
default:
|
||||
err = errors.New("unsupported type")
|
||||
logger.Error().Err(err).Str("type", t.String()).Str("mapKey", mapKey).Msg("target field type for value of mapKey is not supported")
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("mapKey", mapKey).Msg("unmarshalling failed")
|
||||
continue
|
||||
}
|
||||
newValue.Elem().Set(reflect.ValueOf(tmp).Convert(field.Type().Elem()))
|
||||
field.Set(newValue)
|
||||
nonEmpty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nonEmpty
|
||||
// metadataToFacet builds a DriveItem facet *T from CS3 arbitrary metadata under
|
||||
// the "libre.graph.<facet>." key prefix. Nil when no such keys are present.
|
||||
func metadataToFacet[T any](metadata map[string]string, facet string) *T {
|
||||
return mapping.DeserializeStringsAt[T](metadata, "libre.graph."+facet+".")
|
||||
}
|
||||
|
||||
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
|
||||
|
||||
@@ -39,7 +39,7 @@ To enable OpenSearch as a backend, the following settings must be set:
|
||||
|
||||
Additionally, the following optional settings can be set:
|
||||
|
||||
* `SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME=val` (default: `opencloud-resource`): Name of the OpenSearch index
|
||||
* `SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME=val` (default: `opencloud-resource`): Base name of the OpenSearch index. The running index is suffixed with the schema version (e.g. `opencloud-resource-v3`); a breaking schema change targets a fresh index and leaves the old one in place.
|
||||
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_USERNAME=val`: Username for HTTP Basic Authentication.
|
||||
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_PASSWORD=val`: Password for HTTP Basic Authentication.
|
||||
* `SEARCH_ENGINE_OPEN_SEARCH_CLIENT_HEADER=val`: HTTP headers to include in requests.
|
||||
|
||||
@@ -3,7 +3,6 @@ package bleve
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
|
||||
@@ -45,7 +45,7 @@ func NewBackend(index bleve.Index, queryCreator searchQuery.Creator[query.Query]
|
||||
func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexRequest) (*searchService.SearchIndexResponse, error) {
|
||||
createdQuery, err := b.queryCreator.Create(sir.Query)
|
||||
if err != nil {
|
||||
if searchQuery.IsValidationError(err) {
|
||||
if kql.IsValidationError(err) {
|
||||
return nil, errtypes.BadRequest(err.Error())
|
||||
}
|
||||
return nil, err
|
||||
@@ -74,6 +74,16 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
|
||||
),
|
||||
},
|
||||
)
|
||||
// Scope below the space root: restrict at query level so totals and
|
||||
// paging respect the path too. Path is a case-preserving keyword
|
||||
// (paths act as references, /Foo and /foo are distinct), so the exact
|
||||
// folder or the folder prefix matches all of, and only, the scope.
|
||||
if requestedPath := utils.MakeRelativePath(sir.Ref.Path); requestedPath != "." {
|
||||
q.Conjuncts = append(q.Conjuncts, query.NewDisjunctionQuery([]query.Query{
|
||||
&query.TermQuery{FieldVal: "Path", Term: requestedPath},
|
||||
&query.PrefixQuery{FieldVal: "Path", Prefix: requestedPath + "/"},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
bleveReq := bleve.NewSearchRequest(q)
|
||||
@@ -97,17 +107,6 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
|
||||
matches := make([]*searchMessage.Match, 0, len(res.Hits))
|
||||
totalMatches := res.Total
|
||||
for _, hit := range res.Hits {
|
||||
if sir.Ref != nil {
|
||||
hitPath := strings.TrimSuffix(getFieldValue[string](hit.Fields, "Path"), "/")
|
||||
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
|
||||
isRoot := hitPath == requestedPath
|
||||
|
||||
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
|
||||
totalMatches--
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
rootID, err := storagespace.ParseID(getFieldValue[string](hit.Fields, "RootID"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -136,10 +135,10 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques
|
||||
Tags: getFieldSliceValue[string](hit.Fields, "Tags"),
|
||||
Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"),
|
||||
Highlights: getFragmentValue(hit.Fragments, "Content", 0),
|
||||
Audio: getAudioValue[searchMessage.Audio](hit.Fields),
|
||||
Image: getImageValue[searchMessage.Image](hit.Fields),
|
||||
Location: getLocationValue[searchMessage.GeoCoordinates](hit.Fields),
|
||||
Photo: getPhotoValue[searchMessage.Photo](hit.Fields),
|
||||
Audio: hitToFacet[searchMessage.Audio](hit.Fields, "audio"),
|
||||
Image: hitToFacet[searchMessage.Image](hit.Fields, "image"),
|
||||
Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"),
|
||||
Photo: hitToFacet[searchMessage.Photo](hit.Fields, "photo"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,29 @@ var _ = Describe("Bleve", func() {
|
||||
assertDocCount(rootResource.ID, "Tags:baz", 0)
|
||||
})
|
||||
|
||||
It("finds files by tags case-insensitively", func() {
|
||||
// exercises the []string/[]any sibling-lowercasing branch end-to-end.
|
||||
parentResource.Document.Tags = []string{"Work", "Urgent"}
|
||||
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
|
||||
|
||||
assertDocCount(rootResource.ID, "tag:work", 1) // stored "Work", queried lower
|
||||
assertDocCount(rootResource.ID, "tag:WORK", 1) // queried upper
|
||||
assertDocCount(rootResource.ID, "Tags:Urgent", 1)
|
||||
assertDocCount(rootResource.ID, "tag:missing", 0)
|
||||
})
|
||||
|
||||
It("binds a leading NOT to the term right after it, combined with AND", func() {
|
||||
// regression: a leading NOT next to AND dropped the AND'd term, so
|
||||
// `NOT tag:x AND name:y` matched nothing (a self-contradicting clause).
|
||||
parentResource.Document.Tags = []string{"physik"}
|
||||
childResource.Document.Tags = []string{"mathe"}
|
||||
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
|
||||
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
|
||||
|
||||
assertDocCount(rootResource.ID, "NOT tag:physik AND name:child.pdf", 1) // the mathe child
|
||||
assertDocCount(rootResource.ID, "NOT tag:mathe AND name:parent*", 1) // the physik parent
|
||||
})
|
||||
|
||||
It("finds files by size", func() {
|
||||
parentResource.Document.Size = 12345
|
||||
err := eng.Upsert(parentResource.ID, parentResource)
|
||||
@@ -268,6 +291,81 @@ var _ = Describe("Bleve", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("by path", func() {
|
||||
BeforeEach(func() {
|
||||
for _, r := range []search.Resource{parentResource, childResource, childResource2} {
|
||||
Expect(eng.Upsert(r.ID, r)).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
It("matches a folder and its descendants", func() {
|
||||
assertDocCount(rootResource.ID, `path:"./parent d!r"`, 3)
|
||||
})
|
||||
|
||||
It("matches a descendant path only itself", func() {
|
||||
assertDocCount(rootResource.ID, `path:"./parent d!r/child.pdf"`, 1)
|
||||
})
|
||||
|
||||
It("matches case-sensitively", func() {
|
||||
// paths act as references: /Foo and /foo are distinct siblings,
|
||||
// so a wrong-cased path must not match
|
||||
assertDocCount(rootResource.ID, `path:"./PARENT D!R"`, 0)
|
||||
})
|
||||
|
||||
It("applies an AND filter to the folder itself, not only descendants", func() {
|
||||
// regression: the folder-itself clause used to match unconditionally
|
||||
// under an AND, so the parent leaked in despite the name filter.
|
||||
matches := assertDocCount(rootResource.ID, `path:"./parent d!r" AND name:child.pdf`, 1)
|
||||
Expect(matches[0].Entity.Name).To(Equal("child.pdf"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("by content", func() {
|
||||
It("matches full-text case-insensitively and stemmed", func() {
|
||||
parentResource.Document.Content = "Running Foxes"
|
||||
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
|
||||
|
||||
assertDocCount(rootResource.ID, "content:running", 1)
|
||||
assertDocCount(rootResource.ID, "content:RUNNING", 1) // case-insensitive
|
||||
assertDocCount(rootResource.ID, "content:run", 1) // porter stemming
|
||||
assertDocCount(rootResource.ID, "content:run*", 1) // wildcard over the stemmed term
|
||||
assertDocCount(rootResource.ID, "content:cat", 0)
|
||||
})
|
||||
})
|
||||
|
||||
Context("by mediatype", func() {
|
||||
It("matches categories and literal MIME types (incl. + and /)", func() {
|
||||
childResource.Document.MimeType = "image/svg+xml"
|
||||
childResource2.Document.MimeType = "image/png"
|
||||
for _, r := range []search.Resource{childResource, childResource2} {
|
||||
Expect(eng.Upsert(r.ID, r)).To(Succeed())
|
||||
}
|
||||
|
||||
assertDocCount(rootResource.ID, "mediatype:image", 2) // image/* wildcard -> both
|
||||
assertDocCount(rootResource.ID, "mediatype:IMAGE", 2) // categories are case-insensitive
|
||||
assertDocCount(rootResource.ID, "mediatype:pdf", 0)
|
||||
// literal MIME with + and /, must hit only the svg doc, not the png
|
||||
assertDocCount(rootResource.ID, "mediatype:image/svg+xml", 1)
|
||||
assertDocCount(rootResource.ID, "mediatype:image/png", 1)
|
||||
// the same literal via the raw field name (no mediatype alias)
|
||||
assertDocCount(rootResource.ID, "MimeType:image/svg+xml", 1)
|
||||
assertDocCount(rootResource.ID, "MimeType:image/png", 1)
|
||||
})
|
||||
|
||||
It("combines mediatype:file with another term", func() {
|
||||
// regression: mediatype:file (a NOT) next to an operator dropped the
|
||||
// other operand, so mediatype:file AND name:x matched nothing.
|
||||
parentResource.Document.MimeType = "httpd/unix-directory" // a folder
|
||||
childResource.Document.MimeType = "image/png" // a file
|
||||
for _, r := range []search.Resource{parentResource, childResource} {
|
||||
Expect(eng.Upsert(r.ID, r)).To(Succeed())
|
||||
}
|
||||
assertDocCount(rootResource.ID, "mediatype:file", 1) // only the file
|
||||
assertDocCount(rootResource.ID, "mediatype:file AND name:child.pdf", 1) // file AND its name
|
||||
assertDocCount(rootResource.ID, "mediatype:file AND name:nope", 0)
|
||||
})
|
||||
})
|
||||
|
||||
Context("Highlights", func() {
|
||||
|
||||
It("highlights only for content searches", func() {
|
||||
@@ -379,6 +477,61 @@ var _ = Describe("Bleve", func() {
|
||||
|
||||
})
|
||||
|
||||
Describe("path scoped searches", func() {
|
||||
BeforeEach(func() {
|
||||
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
|
||||
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
|
||||
Expect(eng.Upsert(childResource2.ID, childResource2)).To(Succeed())
|
||||
outside := search.Resource{
|
||||
ID: "1$2!6",
|
||||
ParentID: rootResource.ID,
|
||||
RootID: rootResource.ID,
|
||||
Path: "./other/child3.pdf",
|
||||
Type: uint64(sprovider.ResourceType_RESOURCE_TYPE_FILE),
|
||||
Document: content.Document{Name: "child3.pdf"},
|
||||
}
|
||||
Expect(eng.Upsert(outside.ID, outside)).To(Succeed())
|
||||
})
|
||||
|
||||
It("restricts hits and totals to the scope at query level", func() {
|
||||
// without the scope all three children match
|
||||
res, err := doSearch(rootResource.ID, "name:child*", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(3)))
|
||||
|
||||
res, err = doSearch(rootResource.ID, "name:child*", "./parent d!r")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(2)))
|
||||
Expect(len(res.Matches)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("keeps totals right on a small page", func() {
|
||||
// the scope is part of the query, so totals cover the full scope
|
||||
// even when the page holds a single hit
|
||||
rID, err := storagespace.ParseID(rootResource.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := eng.Search(context.Background(), &searchsvc.SearchIndexRequest{
|
||||
Query: "name:child*",
|
||||
PageSize: 1,
|
||||
Ref: &searchmsg.Reference{
|
||||
ResourceId: &searchmsg.ResourceID{
|
||||
StorageId: rID.StorageId, SpaceId: rID.SpaceId, OpaqueId: rID.OpaqueId,
|
||||
},
|
||||
Path: "./parent d!r",
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(2)))
|
||||
Expect(len(res.Matches)).To(Equal(1))
|
||||
})
|
||||
|
||||
It("matches the scope case-sensitively", func() {
|
||||
res, err := doSearch(rootResource.ID, "name:child*", "./PARENT D!R")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.TotalMatches).To(Equal(int32(0)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Upsert", func() {
|
||||
It("adds a resourceInfo to the index", func() {
|
||||
err := eng.Upsert(childResource.ID, childResource)
|
||||
@@ -555,6 +708,21 @@ var _ = Describe("Bleve", func() {
|
||||
Expect(matches[0].Entity.Ref.Path).To(Equal("./somewhere/else/newname"))
|
||||
|
||||
})
|
||||
|
||||
It("keeps case-insensitive search working after a move", func() {
|
||||
Expect(eng.Upsert(parentResource.ID, parentResource)).To(Succeed())
|
||||
Expect(eng.Upsert(childResource.ID, childResource)).To(Succeed())
|
||||
|
||||
Expect(eng.Move(parentResource.ID, parentResource.ParentID, "./my/NewName")).To(Succeed())
|
||||
|
||||
// the lowercased name sibling is rebuilt, so a case-insensitive
|
||||
// name query still works; the path is case-sensitive by design, so
|
||||
// only the exact new path matches (and the old one no longer does).
|
||||
assertDocCount(rootResource.ID, "name:NEWNAME", 1)
|
||||
assertDocCount(rootResource.ID, `path:"./my/NewName"`, 2)
|
||||
assertDocCount(rootResource.ID, `path:"./MY/NEWNAME"`, 0)
|
||||
assertDocCount(rootResource.ID, `path:"./parent d!r"`, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("StartBatch", func() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
@@ -36,10 +37,21 @@ func NewBatch(index bleve.Index, size int) (*Batch, error) {
|
||||
|
||||
func (b *Batch) Upsert(id string, r search.Resource) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
return b.batch.Index(id, r)
|
||||
return b.indexResource(id, r)
|
||||
})
|
||||
}
|
||||
|
||||
// indexResource prepares r for bleve (resolving json tags and splicing in
|
||||
// type-specific adaptations via the mapping package) and appends it to the
|
||||
// batch under id.
|
||||
func (b *Batch) indexResource(id string, r search.Resource) error {
|
||||
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.batch.Index(id, doc)
|
||||
}
|
||||
|
||||
func (b *Batch) Move(id, parentID, location string) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
rootResource, err := searchResourceByID(id, b.index)
|
||||
@@ -68,7 +80,7 @@ func (b *Batch) Move(id, parentID, location string) error {
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
if err := b.batch.Index(resource.ID, resource); err != nil {
|
||||
if err := b.indexResource(resource.ID, *resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.batch.Size() >= b.size {
|
||||
@@ -90,7 +102,7 @@ func (b *Batch) Delete(id string) error {
|
||||
}
|
||||
|
||||
for _, resource := range affectedResources {
|
||||
if err := b.batch.Index(resource.ID, resource); err != nil {
|
||||
if err := b.indexResource(resource.ID, *resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.batch.Size() >= b.size {
|
||||
@@ -112,7 +124,7 @@ func (b *Batch) Restore(id string) error {
|
||||
}
|
||||
|
||||
for _, resource := range affectedResources {
|
||||
if err := b.batch.Index(resource.ID, resource); err != nil {
|
||||
if err := b.indexResource(resource.ID, *resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.batch.Size() >= b.size {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
bleveSearch "github.com/blevesearch/bleve/v2/search"
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
@@ -75,131 +70,19 @@ func getFragmentValue(m bleveSearch.FieldFragmentMap, key string, idx int) strin
|
||||
return val[idx]
|
||||
}
|
||||
|
||||
func getAudioValue[T any](fields map[string]any) *T {
|
||||
if !strings.HasPrefix(getFieldValue[string](fields, "MimeType"), "audio/") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var audio = newPointerOfType[T]()
|
||||
if ok := unmarshalInterfaceMap(audio, fields, "audio."); ok {
|
||||
return audio
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getImageValue[T any](fields map[string]any) *T {
|
||||
var image = newPointerOfType[T]()
|
||||
if ok := unmarshalInterfaceMap(image, fields, "image."); ok {
|
||||
return image
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLocationValue[T any](fields map[string]any) *T {
|
||||
var location = newPointerOfType[T]()
|
||||
if ok := unmarshalInterfaceMap(location, fields, "location."); ok {
|
||||
return location
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPhotoValue[T any](fields map[string]any) *T {
|
||||
var photo = newPointerOfType[T]()
|
||||
if ok := unmarshalInterfaceMap(photo, fields, "photo."); ok {
|
||||
return photo
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPointerOfType[T any]() *T {
|
||||
t := reflect.TypeOf((*T)(nil)).Elem()
|
||||
ptr := reflect.New(t).Interface()
|
||||
return ptr.(*T)
|
||||
}
|
||||
|
||||
func unmarshalInterfaceMap(out any, flatMap map[string]any, prefix string) bool {
|
||||
nonEmpty := false
|
||||
obj := reflect.ValueOf(out).Elem()
|
||||
for i := 0; i < obj.NumField(); i++ {
|
||||
field := obj.Field(i)
|
||||
structField := obj.Type().Field(i)
|
||||
mapKey := prefix + getFieldName(structField)
|
||||
|
||||
if value, ok := flatMap[mapKey]; ok {
|
||||
if field.Kind() == reflect.Ptr {
|
||||
alloc := reflect.New(field.Type().Elem())
|
||||
elemType := field.Type().Elem()
|
||||
|
||||
// convert time strings from index for search requests
|
||||
if elemType == reflect.TypeOf(timestamppb.Timestamp{}) {
|
||||
if strValue, ok := value.(string); ok {
|
||||
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
|
||||
alloc.Elem().Set(reflect.ValueOf(*timestamppb.New(parsedTime)))
|
||||
field.Set(alloc)
|
||||
nonEmpty = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// convert time strings from index for libregraph structs when updating resources
|
||||
if elemType == reflect.TypeOf(time.Time{}) {
|
||||
if strValue, ok := value.(string); ok {
|
||||
if parsedTime, err := time.Parse(time.RFC3339, strValue); err == nil {
|
||||
alloc.Elem().Set(reflect.ValueOf(parsedTime))
|
||||
field.Set(alloc)
|
||||
nonEmpty = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
alloc.Elem().Set(reflect.ValueOf(value).Convert(elemType))
|
||||
field.Set(alloc)
|
||||
nonEmpty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nonEmpty
|
||||
}
|
||||
|
||||
func getFieldName(structField reflect.StructField) string {
|
||||
tag := structField.Tag.Get("json")
|
||||
if tag == "" {
|
||||
return structField.Name
|
||||
}
|
||||
|
||||
return strings.Split(tag, ",")[0]
|
||||
// hitToFacet builds a search Entity facet *T from a bleve hit's fields under the
|
||||
// given key prefix. Nil when the hit has no such fields.
|
||||
func hitToFacet[T any](fields map[string]any, prefix string) *T {
|
||||
return mapping.DeserializeAt[T](fields, prefix)
|
||||
}
|
||||
|
||||
// matchToResource reconstructs a search.Resource from a bleve hit. Used by
|
||||
// the Move / Delete / Restore / Purge paths that round-trip a record through
|
||||
// the index. Always returns a non-nil *Resource: Deserialize is fail-soft
|
||||
// for per-field parse errors, so corrupted hit values surface as zero
|
||||
// values on individual fields instead of dropping the whole record.
|
||||
func matchToResource(match *bleveSearch.DocumentMatch) *search.Resource {
|
||||
return &search.Resource{
|
||||
ID: getFieldValue[string](match.Fields, "ID"),
|
||||
RootID: getFieldValue[string](match.Fields, "RootID"),
|
||||
Path: getFieldValue[string](match.Fields, "Path"),
|
||||
ParentID: getFieldValue[string](match.Fields, "ParentID"),
|
||||
Type: uint64(getFieldValue[float64](match.Fields, "Type")),
|
||||
Deleted: getFieldValue[bool](match.Fields, "Deleted"),
|
||||
Document: content.Document{
|
||||
Name: getFieldValue[string](match.Fields, "Name"),
|
||||
Title: getFieldValue[string](match.Fields, "Title"),
|
||||
Size: uint64(getFieldValue[float64](match.Fields, "Size")),
|
||||
Mtime: getFieldValue[string](match.Fields, "Mtime"),
|
||||
MimeType: getFieldValue[string](match.Fields, "MimeType"),
|
||||
Content: getFieldValue[string](match.Fields, "Content"),
|
||||
Tags: getFieldSliceValue[string](match.Fields, "Tags"),
|
||||
Favorites: getFieldSliceValue[string](match.Fields, "Favorites"),
|
||||
Audio: getAudioValue[libregraph.Audio](match.Fields),
|
||||
Image: getImageValue[libregraph.Image](match.Fields),
|
||||
Location: getLocationValue[libregraph.GeoCoordinates](match.Fields),
|
||||
Photo: getPhotoValue[libregraph.Photo](match.Fields),
|
||||
},
|
||||
}
|
||||
return mapping.Deserialize[search.Resource](match.Fields)
|
||||
}
|
||||
|
||||
func escapeQuery(s string) string {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package bleve_test
|
||||
|
||||
import (
|
||||
bleveSearch "github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// geoFixture builds an in-memory bleve index with a single resource carrying
|
||||
// the given lon/lat/alt, indexed through the full bleve pipeline.
|
||||
func geoFixture(lon, lat, alt float64) bleveSearch.Index {
|
||||
idxMapping, err := bleve.NewMapping()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
idx, err := bleveSearch.NewMemOnly(idxMapping)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := search.Resource{
|
||||
ID: "x",
|
||||
Document: content.Document{
|
||||
Name: "team.jpg",
|
||||
Location: &libregraph.GeoCoordinates{
|
||||
Longitude: &lon,
|
||||
Latitude: &lat,
|
||||
Altitude: &alt,
|
||||
},
|
||||
},
|
||||
}
|
||||
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx.Index(r.ID, doc)).To(Succeed())
|
||||
return idx
|
||||
}
|
||||
|
||||
var _ = Describe("Location geo queries", func() {
|
||||
// Every Location subfield (including altitude) must end up in hit.Fields
|
||||
// when a Resource is indexed through the full bleve pipeline. This is the
|
||||
// invariant the Move / Delete / Restore round-trip depends on.
|
||||
It("round-trips every Location subfield into hit.Fields", func() {
|
||||
idx := geoFixture(11.103870357204285, 49.48675890884328, 1047.7)
|
||||
|
||||
req := bleveSearch.NewSearchRequest(bleveSearch.NewMatchAllQuery())
|
||||
req.Fields = []string{"*"}
|
||||
res, err := idx.Search(req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).ToNot(BeEmpty())
|
||||
|
||||
for _, k := range []string{"location.longitude", "location.latitude", "location.altitude"} {
|
||||
Expect(res.Hits[0].Fields).To(HaveKey(k))
|
||||
}
|
||||
})
|
||||
|
||||
It("matches a latitude numeric range and misses outside it", func() {
|
||||
idx := geoFixture(11.1, 49.48, 1000)
|
||||
|
||||
min, max := 49.0, 50.0
|
||||
incl := true
|
||||
q := query.NewNumericRangeInclusiveQuery(&min, &max, &incl, &incl)
|
||||
q.SetField("location.latitude")
|
||||
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).To(HaveLen(1))
|
||||
|
||||
lowMin, lowMax := 0.0, 10.0
|
||||
q2 := query.NewNumericRangeInclusiveQuery(&lowMin, &lowMax, &incl, &incl)
|
||||
q2.SetField("location.latitude")
|
||||
res, err = idx.Search(bleveSearch.NewSearchRequest(q2))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("matches a longitude numeric range", func() {
|
||||
idx := geoFixture(11.1, 49.48, 1000)
|
||||
|
||||
min, max := 11.0, 12.0
|
||||
incl := true
|
||||
q := query.NewNumericRangeInclusiveQuery(&min, &max, &incl, &incl)
|
||||
q.SetField("location.longitude")
|
||||
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("matches an altitude lower-bound range and misses above it", func() {
|
||||
idx := geoFixture(11.1, 49.48, 1047.7)
|
||||
|
||||
min := 1000.0
|
||||
incl := true
|
||||
q := query.NewNumericRangeInclusiveQuery(&min, nil, &incl, nil)
|
||||
q.SetField("location.altitude")
|
||||
res, err := idx.Search(bleveSearch.NewSearchRequest(q))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).To(HaveLen(1))
|
||||
|
||||
highMin := 2000.0
|
||||
q2 := query.NewNumericRangeInclusiveQuery(&highMin, nil, &incl, nil)
|
||||
q2.SetField("location.altitude")
|
||||
res, err = idx.Search(bleveSearch.NewSearchRequest(q2))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("matches a geo-distance query near the point and misses far away", func() {
|
||||
// Nuremberg-ish coordinates.
|
||||
idx := geoFixture(11.103870357204285, 49.48675890884328, 1047.7)
|
||||
|
||||
// 10 km radius around the indexed point should match.
|
||||
near := query.NewGeoDistanceQuery(11.103870357204285, 49.48675890884328, "10km")
|
||||
near.SetField("location" + mapping.GeopointSuffix)
|
||||
res, err := idx.Search(bleveSearch.NewSearchRequest(near))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).To(HaveLen(1))
|
||||
|
||||
// Far away (Berlin, ~400 km) with a 10 km radius should miss.
|
||||
far := query.NewGeoDistanceQuery(13.404954, 52.520008, "10km")
|
||||
far.SetField("location" + mapping.GeopointSuffix)
|
||||
res, err = idx.Search(bleveSearch.NewSearchRequest(far))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Hits).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
@@ -1,76 +1,205 @@
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"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/opencloud-eu/opencloud/pkg/log"
|
||||
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/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) {
|
||||
// bolt_timeout makes a second process on the same datapath fail after 5s
|
||||
// instead of blocking forever on the file lock.
|
||||
var openRuntimeConfig = map[string]interface{}{"bolt_timeout": "5s"}
|
||||
|
||||
// NewIndex opens (or creates) the bleve index at root and reconciles its schema
|
||||
// against NewMapping() via searchmapping.Reconcile.
|
||||
func NewIndex(root string, logger log.Logger) (bleve.Index, searchmapping.Classification, error) {
|
||||
destination := filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion))
|
||||
index, err := bleve.OpenUsing(destination, openRuntimeConfig)
|
||||
if errors.Is(err, bleve.ErrorIndexPathDoesNotExist) {
|
||||
indexMapping, err := NewMapping()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, searchmapping.Classification{}, err
|
||||
}
|
||||
index, err = bleve.New(destination, indexMapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, searchmapping.Classification{}, err
|
||||
}
|
||||
|
||||
return index, nil
|
||||
searchmapping.LogNewIndexCreated(logger, destination)
|
||||
return index, searchmapping.Classification{Verdict: searchmapping.VerdictEqual}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, searchmapping.Classification{}, err
|
||||
}
|
||||
|
||||
return index, err
|
||||
r := &bleveReconciler{index: index, destination: destination}
|
||||
classification, err := searchmapping.Reconcile(destination, r, logger)
|
||||
if err != nil {
|
||||
if r.index != nil {
|
||||
_ = r.index.Close()
|
||||
}
|
||||
return nil, classification, err
|
||||
}
|
||||
|
||||
return r.index, classification, nil
|
||||
}
|
||||
|
||||
// bleveReconciler adapts a bleve index to searchmapping.SchemaReconciler.
|
||||
type bleveReconciler struct {
|
||||
index bleve.Index
|
||||
destination string
|
||||
codeB []byte // marshaled code mapping, produced by Classify, used by ApplyAdditive
|
||||
}
|
||||
|
||||
func (r *bleveReconciler) Classify() (searchmapping.Classification, error) {
|
||||
classification, codeB, err := classifyStoredMapping(r.index)
|
||||
r.codeB = codeB
|
||||
return classification, err
|
||||
}
|
||||
|
||||
// ApplyAdditive persists the code mapping and reopens so the live mapping picks
|
||||
// it up. persisted=true once SetInternal succeeds, even if the reopen then
|
||||
// fails; on error it closes the index and clears the handle.
|
||||
func (r *bleveReconciler) ApplyAdditive() (bool, error) {
|
||||
if err := r.index.SetInternal([]byte("_mapping"), r.codeB); err != nil {
|
||||
_ = r.index.Close()
|
||||
r.index = nil
|
||||
return false, fmt.Errorf("failed to store the updated index mapping: %w", err)
|
||||
}
|
||||
if err := r.index.Close(); err != nil {
|
||||
r.index = nil
|
||||
return true, err
|
||||
}
|
||||
index, err := bleve.OpenUsing(r.destination, openRuntimeConfig)
|
||||
if err != nil {
|
||||
r.index = nil
|
||||
return true, err
|
||||
}
|
||||
r.index = index
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// classifyStoredMapping diffs the stored mapping against NewMapping() and
|
||||
// returns the marshaled code mapping. New-in-code fields that already hold
|
||||
// data (previously indexed dynamically) are breaking. The compare is only
|
||||
// stable within one bleve version: a changed marshaling default fails towards
|
||||
// breaking, normalize the affected key here if that ever fires.
|
||||
func classifyStoredMapping(index bleve.Index) (searchmapping.Classification, []byte, error) {
|
||||
storedB, err := index.GetInternal([]byte("_mapping"))
|
||||
if err != nil {
|
||||
return searchmapping.Classification{}, nil, fmt.Errorf("failed to read the stored index mapping: %w", err)
|
||||
}
|
||||
codeMapping, err := NewMapping()
|
||||
if err != nil {
|
||||
return searchmapping.Classification{}, nil, err
|
||||
}
|
||||
codeB, err := json.Marshal(codeMapping)
|
||||
if err != nil {
|
||||
return searchmapping.Classification{}, nil, err
|
||||
}
|
||||
|
||||
var stored, code map[string]any
|
||||
if err := json.Unmarshal(storedB, &stored); err != nil {
|
||||
return searchmapping.Classification{}, nil, fmt.Errorf("failed to parse the stored index mapping: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(codeB, &code); err != nil {
|
||||
return searchmapping.Classification{}, nil, err
|
||||
}
|
||||
|
||||
fields, err := index.Fields()
|
||||
if err != nil {
|
||||
return searchmapping.Classification{}, nil, fmt.Errorf("failed to list the indexed fields: %w", err)
|
||||
}
|
||||
indexedFields := make(map[string]struct{}, len(fields))
|
||||
for _, f := range fields {
|
||||
if !strings.HasPrefix(f, "_") { // skip bleve-internal fields like _all
|
||||
indexedFields[f] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
storedDM, _ := stored["default_mapping"].(map[string]any)
|
||||
codeDM, _ := code["default_mapping"].(map[string]any)
|
||||
storedProps, _ := storedDM["properties"].(map[string]any)
|
||||
codeProps, _ := codeDM["properties"].(map[string]any)
|
||||
|
||||
classification := searchmapping.Classify(storedProps, codeProps, func(path string) bool {
|
||||
if _, ok := indexedFields[path]; ok {
|
||||
return true
|
||||
}
|
||||
nested := path + "."
|
||||
for f := range indexedFields {
|
||||
if strings.HasPrefix(f, nested) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// everything outside default_mapping.properties (analyzer definitions,
|
||||
// default analyzer, dynamic flags, ...) must match exactly
|
||||
var reasons []string
|
||||
compareKeysExcept(stored, code, "default_mapping", "", &reasons)
|
||||
compareKeysExcept(storedDM, codeDM, "properties", "default_mapping.", &reasons)
|
||||
if len(reasons) > 0 {
|
||||
classification.Verdict = searchmapping.VerdictBreaking
|
||||
classification.Reasons = append(reasons, classification.Reasons...)
|
||||
}
|
||||
|
||||
return classification, codeB, nil
|
||||
}
|
||||
|
||||
// compareKeysExcept deep-compares all keys present on either side except skip.
|
||||
func compareKeysExcept(stored, code map[string]any, skip, prefix string, reasons *[]string) {
|
||||
keys := slices.Collect(maps.Keys(stored))
|
||||
for k := range code {
|
||||
if _, ok := stored[k]; !ok {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, k := range keys {
|
||||
if k == skip {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(stored[k], code[k]) {
|
||||
*reasons = append(*reasons, fmt.Sprintf("%s%s changed", prefix, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
)
|
||||
resourceType := reflect.TypeFor[search.Resource]()
|
||||
overrides := search.Resource{}.SearchFieldOverrides()
|
||||
if err := searchmapping.Validate(resourceType, overrides); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
docMapping, err := searchmapping.BleveBuildMapping(resourceType, overrides)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
indexMapping := bleve.NewIndexMapping()
|
||||
indexMapping.DefaultAnalyzer = keyword.Name
|
||||
indexMapping.DefaultMapping = docMapping
|
||||
err = indexMapping.AddCustomAnalyzer("fulltext",
|
||||
map[string]any{
|
||||
"type": custom.Name,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package bleve_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
bleveSearch "github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
bleveMapping "github.com/blevesearch/bleve/v2/mapping"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
|
||||
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
var _ = Describe("NewIndex", func() {
|
||||
var root string
|
||||
|
||||
BeforeEach(func() {
|
||||
root = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
codeMapping := func() *bleveMapping.IndexMappingImpl {
|
||||
m, err := bleve.NewMapping()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
impl, ok := m.(*bleveMapping.IndexMappingImpl)
|
||||
Expect(ok).To(BeTrue())
|
||||
return impl
|
||||
}
|
||||
|
||||
// buildIndex simulates an index left behind by an older release
|
||||
buildIndex := func(m bleveMapping.IndexMapping, docs map[string]map[string]any) {
|
||||
idx, err := bleveSearch.New(filepath.Join(root, fmt.Sprintf("bleve-v%d", search.SchemaVersion)), m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for id, doc := range docs {
|
||||
Expect(idx.Index(id, doc)).To(Succeed())
|
||||
}
|
||||
Expect(idx.Close()).To(Succeed())
|
||||
}
|
||||
|
||||
It("creates a fresh index", func() {
|
||||
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(classification.Verdict).To(Equal(searchmapping.VerdictEqual))
|
||||
Expect(idx.Close()).To(Succeed())
|
||||
})
|
||||
|
||||
It("opens an index with an identical schema", func() {
|
||||
buildIndex(codeMapping(), nil)
|
||||
|
||||
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(classification.Verdict).To(Equal(searchmapping.VerdictEqual))
|
||||
Expect(classification.NewFields).To(BeEmpty())
|
||||
Expect(idx.Close()).To(Succeed())
|
||||
})
|
||||
|
||||
It("treats a genuinely new field as additive", func() {
|
||||
old := codeMapping()
|
||||
Expect(old.DefaultMapping.Properties).To(HaveKey("Title"))
|
||||
delete(old.DefaultMapping.Properties, "Title")
|
||||
buildIndex(old, nil)
|
||||
|
||||
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(classification.Verdict).To(Equal(searchmapping.VerdictAdditive))
|
||||
Expect(classification.NewFields).To(ConsistOf("Title"))
|
||||
Expect(idx.Index("1", map[string]any{"Title": "hello"})).To(Succeed())
|
||||
Expect(idx.Close()).To(Succeed())
|
||||
})
|
||||
|
||||
It("treats a new nested field as additive", func() {
|
||||
old := codeMapping()
|
||||
photo := old.DefaultMapping.Properties["photo"]
|
||||
Expect(photo).ToNot(BeNil())
|
||||
Expect(photo.Properties).To(HaveKey("cameraMake"))
|
||||
delete(photo.Properties, "cameraMake")
|
||||
buildIndex(old, nil)
|
||||
|
||||
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(classification.Verdict).To(Equal(searchmapping.VerdictAdditive))
|
||||
Expect(classification.NewFields).To(ConsistOf("photo.cameraMake"))
|
||||
Expect(idx.Close()).To(Succeed())
|
||||
})
|
||||
|
||||
It("persists an additive schema change so later startups classify it as equal", func() {
|
||||
old := codeMapping()
|
||||
delete(old.DefaultMapping.Properties, "Title")
|
||||
buildIndex(old, nil)
|
||||
|
||||
idx, classification, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(classification.Verdict).To(Equal(searchmapping.VerdictAdditive))
|
||||
Expect(idx.Index("1", map[string]any{"Title": "hello"})).To(Succeed())
|
||||
Expect(idx.Close()).To(Succeed())
|
||||
|
||||
idx, classification, err = bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(classification.Verdict).To(Equal(searchmapping.VerdictEqual))
|
||||
Expect(idx.Close()).To(Succeed())
|
||||
})
|
||||
|
||||
It("refuses when a new field already has data in the index", func() {
|
||||
old := codeMapping()
|
||||
Expect(old.DefaultMapping.Properties).To(HaveKey("Mtime"))
|
||||
delete(old.DefaultMapping.Properties, "Mtime")
|
||||
buildIndex(old, map[string]map[string]any{"1": {"Mtime": "2026-01-02T03:04:05Z"}})
|
||||
|
||||
idx, _, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
|
||||
Expect(idx).To(BeNil())
|
||||
})
|
||||
|
||||
It("refuses when a new object field already has nested data in the index", func() {
|
||||
old := codeMapping()
|
||||
Expect(old.DefaultMapping.Properties).To(HaveKey("photo"))
|
||||
delete(old.DefaultMapping.Properties, "photo")
|
||||
buildIndex(old, map[string]map[string]any{"1": {"photo": map[string]any{"cameraMake": "ACME"}}})
|
||||
|
||||
_, _, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
|
||||
})
|
||||
|
||||
It("refuses on a changed field definition", func() {
|
||||
old := codeMapping()
|
||||
name := old.DefaultMapping.Properties["Name"]
|
||||
Expect(name).ToNot(BeNil())
|
||||
Expect(name.Fields).ToNot(BeEmpty())
|
||||
name.Fields[0].Analyzer = "fulltext"
|
||||
buildIndex(old, nil)
|
||||
|
||||
_, _, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
|
||||
})
|
||||
|
||||
It("refuses when a stored field was removed from the code schema", func() {
|
||||
old := codeMapping()
|
||||
old.DefaultMapping.AddFieldMappingsAt("Legacy", bleveSearch.NewTextFieldMapping())
|
||||
buildIndex(old, nil)
|
||||
|
||||
_, _, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
|
||||
})
|
||||
|
||||
It("refuses when a default_mapping attribute changed", func() {
|
||||
old := codeMapping()
|
||||
old.DefaultMapping.Dynamic = false
|
||||
buildIndex(old, nil)
|
||||
|
||||
_, _, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
|
||||
})
|
||||
|
||||
It("refuses on a changed analyzer definition", func() {
|
||||
old := codeMapping()
|
||||
Expect(old.CustomAnalysis.Analyzers).To(HaveKey("fulltext"))
|
||||
old.CustomAnalysis.Analyzers["fulltext"] = map[string]any{
|
||||
"type": custom.Name,
|
||||
"tokenizer": unicode.Name,
|
||||
"token_filters": []string{lowercase.Name},
|
||||
}
|
||||
buildIndex(old, nil)
|
||||
|
||||
_, _, err := bleve.NewIndex(root, log.NopLogger())
|
||||
Expect(err).To(MatchError(searchmapping.ErrManualActionRequired))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("NewMapping", func() {
|
||||
It("only references registered analyzers", func() {
|
||||
m, err := bleve.NewMapping()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
impl, ok := m.(*bleveMapping.IndexMappingImpl)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(impl.Validate()).To(Succeed())
|
||||
})
|
||||
|
||||
// A diff here means existing indexes will classify as breaking (schema or
|
||||
// bleve marshaling changed); update the golden file only deliberately.
|
||||
It("matches the committed golden mapping", func() {
|
||||
m, err := bleve.NewMapping()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
b, err := json.Marshal(m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var got, golden map[string]any
|
||||
Expect(json.Unmarshal(b, &got)).To(Succeed())
|
||||
|
||||
goldenB, err := os.ReadFile("testdata/mapping.golden.json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(json.Unmarshal(goldenB, &golden)).To(Succeed())
|
||||
|
||||
Expect(got).To(Equal(golden))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
package bleve_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
|
||||
bleveSearch "github.com/blevesearch/bleve/v2"
|
||||
bquery "github.com/blevesearch/bleve/v2/search/query"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/bleve"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// Mtime is typed as a date, so range queries are chronological, not a
|
||||
// lexicographic keyword compare.
|
||||
var _ = Describe("Mtime date range", func() {
|
||||
It("compares chronologically, not lexicographically", func() {
|
||||
m, err := bleve.NewMapping()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
idx, err := bleveSearch.NewMemOnly(m)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := search.Resource{ID: "x", Document: content.Document{Name: "f", Mtime: conversions.ToPointer(time.Date(2026, 3, 15, 12, 0, 0, 123456789, time.UTC))}}
|
||||
doc, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx.Index(r.ID, doc)).To(Succeed())
|
||||
|
||||
hits := func(qs string) uint64 {
|
||||
res, err := idx.Search(bleveSearch.NewSearchRequest(bquery.NewQueryStringQuery(qs)))
|
||||
Expect(err).ToNot(HaveOccurred(), qs)
|
||||
return res.Total
|
||||
}
|
||||
Expect(hits(`Mtime:>"2026-01-01T00:00:00Z"`)).To(Equal(uint64(1)), "in-range")
|
||||
Expect(hits(`Mtime:>"2026-06-01T00:00:00Z"`)).To(Equal(uint64(0)), "out-of-range")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,720 @@
|
||||
{
|
||||
"default_mapping": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"properties": {
|
||||
"Content": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "fulltext",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Deleted": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Favorites": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Favorites_lowercase": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"index": true,
|
||||
"include_term_vectors": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Hidden": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"ID": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"MimeType": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Mtime": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "datetime",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Name": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Name_lowercase": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"index": true,
|
||||
"include_term_vectors": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"ParentID": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Path": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"RootID": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Size": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Tags": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Tags_lowercase": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"index": true,
|
||||
"include_term_vectors": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Title": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"Type": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"audio": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"properties": {
|
||||
"album": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"albumArtist": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"artist": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"bitrate": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"composers": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"copyright": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"disc": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"discCount": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"duration": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"genre": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"hasDrm": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"isVariableBitrate": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"track": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"trackCount": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"year": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"properties": {
|
||||
"height": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"width": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"properties": {
|
||||
"altitude": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"latitude": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"longitude": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"location_geopoint": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "geopoint",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"photo": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"properties": {
|
||||
"cameraMake": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"cameraModel": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "text",
|
||||
"analyzer": "keyword",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_term_vectors": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"exposureDenominator": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"exposureNumerator": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"fNumber": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"focalLength": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"iso": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"orientation": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "number",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"takenDateTime": {
|
||||
"enabled": true,
|
||||
"dynamic": true,
|
||||
"fields": [
|
||||
{
|
||||
"type": "datetime",
|
||||
"store": true,
|
||||
"index": true,
|
||||
"include_in_all": true,
|
||||
"docvalues": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type_field": "_type",
|
||||
"default_type": "_default",
|
||||
"default_analyzer": "keyword",
|
||||
"default_datetime_parser": "dateTimeOptional",
|
||||
"default_field": "_all",
|
||||
"store_dynamic": true,
|
||||
"index_dynamic": true,
|
||||
"docvalues_dynamic": true,
|
||||
"analysis": {
|
||||
"analyzers": {
|
||||
"fulltext": {
|
||||
"token_filters": [
|
||||
"to_lower",
|
||||
"stemmer_porter"
|
||||
],
|
||||
"tokenizer": "unicode",
|
||||
"type": "custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
|
||||
"github.com/opencloud-eu/opencloud/pkg/generators"
|
||||
@@ -30,8 +28,6 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -71,7 +67,7 @@ func Server(cfg *config.Config) *cobra.Command {
|
||||
var eng search.Engine
|
||||
switch cfg.Engine.Type {
|
||||
case "bleve":
|
||||
idx, err := bleve.NewIndex(cfg.Engine.Bleve.Datapath)
|
||||
idx, _, err := bleve.NewIndex(cfg.Engine.Bleve.Datapath, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,42 +80,16 @@ func Server(cfg *config.Config) *cobra.Command {
|
||||
|
||||
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})
|
||||
client, err := opensearch.NewClient(cfg.Engine.OpenSearch.Client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OpenSearch client: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
openSearchBackend, err := opensearch.NewBackend(cfg.Engine.OpenSearch.ResourceIndex.Name, client)
|
||||
// a hung cluster must fail the start, not block it forever
|
||||
startupCtx, cancelStartup := context.WithTimeout(ctx, time.Minute)
|
||||
indexName := opensearch.VersionedIndexName(cfg.Engine.OpenSearch.ResourceIndex.Name)
|
||||
openSearchBackend, err := opensearch.NewBackend(startupCtx, indexName, client, logger)
|
||||
cancelStartup()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OpenSearch backend: %w", err)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type EngineOpenSearch struct {
|
||||
|
||||
// EngineOpenSearchResourceIndex defines the OpenSearch index for resources
|
||||
type EngineOpenSearchResourceIndex struct {
|
||||
Name string `yaml:"name" env:"SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME" desc:"The name of the OpenSearch index for resources." introductionVersion:"4.0.0"`
|
||||
Name string `yaml:"name" env:"SEARCH_ENGINE_OPEN_SEARCH_RESOURCE_INDEX_NAME" desc:"The base name of the OpenSearch index for resources. The running index is suffixed with the schema version, e.g. opencloud-resource-v3." introductionVersion:"4.0.0"`
|
||||
}
|
||||
|
||||
// EngineOpenSearchClient configures the OpenSearch client
|
||||
|
||||
@@ -3,9 +3,9 @@ package content
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/tags"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
@@ -54,7 +54,7 @@ func (b Basic) Extract(_ context.Context, ri *storageProvider.ResourceInfo) (Doc
|
||||
}
|
||||
|
||||
if ri.Mtime != nil {
|
||||
doc.Mtime = utils.TSToTime(ri.Mtime).UTC().Format(time.RFC3339Nano)
|
||||
doc.Mtime = conversions.ToPointer(utils.TSToTime(ri.Mtime).UTC())
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package content_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
@@ -69,11 +73,11 @@ var _ = Describe("Basic", func() {
|
||||
It("RFC3339 mtime", func() {
|
||||
for _, data := range []struct {
|
||||
second uint64
|
||||
expect string
|
||||
expect *time.Time
|
||||
}{
|
||||
{second: 4000, expect: "1970-01-01T01:06:40Z"},
|
||||
{second: 3000, expect: "1970-01-01T00:50:00Z"},
|
||||
{expect: ""},
|
||||
{second: 4000, expect: conversions.ToPointer(time.Unix(4000, 0).UTC())},
|
||||
{second: 3000, expect: conversions.ToPointer(time.Unix(3000, 0).UTC())},
|
||||
{},
|
||||
} {
|
||||
ri := &storageProvider.ResourceInfo{}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package content
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bbalet/stopwords"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
@@ -14,14 +15,14 @@ func init() {
|
||||
// Document wraps all resource meta fields,
|
||||
// it is used as a content extraction result.
|
||||
type Document struct {
|
||||
Title string
|
||||
Name string
|
||||
Content string
|
||||
Size uint64
|
||||
Mtime string
|
||||
MimeType string
|
||||
Tags []string
|
||||
Favorites []string
|
||||
Title string `json:"Title"`
|
||||
Name string `json:"Name"`
|
||||
Content string `json:"Content"`
|
||||
Size uint64 `json:"Size"`
|
||||
Mtime *time.Time `json:"Mtime"`
|
||||
MimeType string `json:"MimeType"`
|
||||
Tags []string `json:"Tags"`
|
||||
Favorites []string `json:"Favorites"`
|
||||
Audio *libregraph.Audio `json:"audio,omitempty"`
|
||||
Image *libregraph.Image `json:"image,omitempty"`
|
||||
Location *libregraph.GeoCoordinates `json:"location,omitempty"`
|
||||
|
||||
@@ -3,16 +3,12 @@ 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/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
|
||||
@@ -94,10 +90,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document,
|
||||
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)
|
||||
}
|
||||
doc.Audio = t.getAudio(meta)
|
||||
}
|
||||
|
||||
if langCode, _ := t.tika.LanguageString(ctx, doc.Content); langCode != "" && t.CleanStopWords {
|
||||
@@ -106,199 +99,3 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document,
|
||||
|
||||
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,120 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "audio:bitrate"); err == nil {
|
||||
// tika emits bits per second, graph wants kbps
|
||||
if bps, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
initAudio()
|
||||
audio.SetBitrate(bps / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:composer"); err == nil {
|
||||
initAudio()
|
||||
audio.SetComposers(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:copyright"); err == nil {
|
||||
initAudio()
|
||||
audio.SetCopyright(v)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "xmpDM:discNumber"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetDisc(int32(i))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "audio:disc-count"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetDiscCount(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "audio:has-drm"); err == nil {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
initAudio()
|
||||
audio.SetHasDrm(b)
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "audio:is-variable-bitrate"); err == nil {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
initAudio()
|
||||
audio.SetIsVariableBitrate(b)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := getFirstValue(meta, "audio:track-count"); err == nil {
|
||||
if i, err := strconv.ParseInt(v, 10, 32); err == nil {
|
||||
initAudio()
|
||||
audio.SetTrackCount(int32(i))
|
||||
}
|
||||
}
|
||||
|
||||
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,54 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
var _ = Describe("getAudio", func() {
|
||||
It("maps the audio metadata to the audio facet", func() {
|
||||
meta := map[string][]string{
|
||||
"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"},
|
||||
"dc:title": {"Some Title"},
|
||||
"xmpDM:duration": {"225.5"},
|
||||
"xmpDM:composer": {"Some Composers"},
|
||||
"xmpDM:copyright": {"Some Copyright"},
|
||||
"audio:bitrate": {"192000"},
|
||||
"audio:is-variable-bitrate": {"true"},
|
||||
"audio:has-drm": {"false"},
|
||||
"audio:track-count": {"9"},
|
||||
"audio:disc-count": {"5"},
|
||||
}
|
||||
|
||||
audio := Tika{}.getAudio(meta)
|
||||
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("returns nil when no audio metadata is present", func() {
|
||||
Expect(Tika{}.getAudio(map[string][]string{})).To(BeNil())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
var _ = Describe("getImage", func() {
|
||||
It("maps the image dimensions to the image facet", func() {
|
||||
image := Tika{}.getImage(map[string][]string{
|
||||
"tiff:ImageWidth": {"100"},
|
||||
"tiff:ImageLength": {"200"},
|
||||
})
|
||||
Expect(image).ToNot(BeNil())
|
||||
Expect(image.Width).To(Equal(libregraph.PtrInt32(100)))
|
||||
Expect(image.Height).To(Equal(libregraph.PtrInt32(200)))
|
||||
})
|
||||
|
||||
It("returns nil when no image metadata is present", func() {
|
||||
Expect(Tika{}.getImage(map[string][]string{})).To(BeNil())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
// graph geoCoordinates.altitude is in feet, exif GPS altitude (geo:alt) in metres.
|
||||
const metresToFeet = 3.280839895
|
||||
|
||||
func (t Tika) getLocation(meta map[string][]string) *libregraph.GeoCoordinates {
|
||||
var location *libregraph.GeoCoordinates
|
||||
initLocation := func() {
|
||||
if location == nil {
|
||||
location = libregraph.NewGeoCoordinates()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// tika emits metres (negative below sea level), graph wants feet
|
||||
if v, err := getFirstValue(meta, "geo:alt"); err == nil {
|
||||
if metres, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
initLocation()
|
||||
location.SetAltitude(metres * metresToFeet)
|
||||
}
|
||||
}
|
||||
|
||||
return location
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
var _ = Describe("getLocation", func() {
|
||||
It("maps lat/long and converts altitude from metres to feet", func() {
|
||||
metres := 227.4
|
||||
location := Tika{}.getLocation(map[string][]string{
|
||||
"geo:lat": {"49.48675890884328"},
|
||||
"geo:long": {"11.103870357204285"},
|
||||
"geo:alt": {"227.4"},
|
||||
})
|
||||
Expect(location).ToNot(BeNil())
|
||||
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
|
||||
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
|
||||
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(metres * metresToFeet)))
|
||||
})
|
||||
|
||||
It("keeps below-sea-level altitude negative", func() {
|
||||
metres := -227.4
|
||||
location := Tika{}.getLocation(map[string][]string{"geo:alt": {"-227.4"}})
|
||||
Expect(location).ToNot(BeNil())
|
||||
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(metres * metresToFeet)))
|
||||
})
|
||||
|
||||
It("returns nil when no location metadata is present", func() {
|
||||
Expect(Tika{}.getLocation(map[string][]string{})).To(BeNil())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
)
|
||||
|
||||
var _ = Describe("getPhoto", func() {
|
||||
It("maps the exif metadata to the photo facet", func() {
|
||||
photo := Tika{}.getPhoto(map[string][]string{
|
||||
"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"},
|
||||
})
|
||||
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("returns nil when no photo metadata is present", func() {
|
||||
Expect(Tika{}.getPhoto(map[string][]string{})).To(BeNil())
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,10 @@ import (
|
||||
"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/opencloud-eu/opencloud/pkg/log"
|
||||
@@ -91,131 +89,6 @@ var _ = Describe("Tika", func() {
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
bleveMapping "github.com/blevesearch/bleve/v2/mapping"
|
||||
)
|
||||
|
||||
// BleveBuildMapping builds a bleve DocumentMapping for t by walking the
|
||||
// struct via reflection. Field names come from json tags; overrides are
|
||||
// keyed by those names (or dotted paths for nested fields).
|
||||
//
|
||||
// The returned mapping references analyzer names (Analyzer field on the
|
||||
// FieldOpts, plus "fulltext" / "path_hierarchy" for the corresponding Types);
|
||||
// the caller must register every referenced analyzer on the enclosing
|
||||
// IndexMapping (IndexMapping.Validate catches missing ones).
|
||||
func BleveBuildMapping(t reflect.Type, overrides map[string]FieldOpts) (*bleveMapping.DocumentMapping, error) {
|
||||
return buildBleveDocMapping(t, overrides, "")
|
||||
}
|
||||
|
||||
func buildBleveDocMapping(t reflect.Type, overrides map[string]FieldOpts, prefix string) (*bleveMapping.DocumentMapping, error) {
|
||||
doc := bleve.NewDocumentMapping()
|
||||
err := walkFields(t, func(fi fieldInfo) error {
|
||||
key := fi.Name
|
||||
if prefix != "" {
|
||||
key = prefix + "." + fi.Name
|
||||
}
|
||||
opts := overrides[key]
|
||||
fieldType := opts.Type
|
||||
if fieldType == "" {
|
||||
fieldType = inferType(fi.GoField.Type)
|
||||
}
|
||||
|
||||
if fieldType == TypeObject {
|
||||
sub := structType(fi.GoField.Type)
|
||||
if sub == nil {
|
||||
return fmt.Errorf("mapping: object type on non-struct field %q", key)
|
||||
}
|
||||
subDoc, err := buildBleveDocMapping(sub, overrides, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc.AddSubDocumentMapping(fi.Name, subDoc)
|
||||
return nil
|
||||
}
|
||||
|
||||
if fieldType == TypeGeopoint {
|
||||
// Keep the facet object, add a sibling _geopoint field (see GeopointSuffix).
|
||||
sub := structType(fi.GoField.Type)
|
||||
if sub == nil {
|
||||
return fmt.Errorf("mapping: geopoint type on non-struct field %q", key)
|
||||
}
|
||||
subDoc, err := buildBleveDocMapping(sub, overrides, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc.AddSubDocumentMapping(fi.Name, subDoc)
|
||||
doc.AddFieldMappingsAt(fi.Name+GeopointSuffix, bleve.NewGeoPointFieldMapping())
|
||||
return nil
|
||||
}
|
||||
|
||||
if fieldType == TypeKeyword || fieldType == TypePath {
|
||||
// bleve has no path tokenizer, so a path is a plain keyword here.
|
||||
base := bleveKeywordMapping(fieldType, opts)
|
||||
doc.AddFieldMappingsAt(fi.Name, base)
|
||||
if opts.caseInsensitive() {
|
||||
doc.AddFieldMappingsAt(fi.Name+LowercaseSuffix, lowercaseSibling(base))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fm, err := bleveFieldMapping(fieldType, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mapping: field %q: %w", key, err)
|
||||
}
|
||||
doc.AddFieldMappingsAt(fi.Name, fm)
|
||||
return nil
|
||||
})
|
||||
return doc, err
|
||||
}
|
||||
|
||||
// bleveKeywordMapping is a case-preserving keyword field; path fields stay out
|
||||
// of _all by default.
|
||||
func bleveKeywordMapping(fieldType string, opts FieldOpts) *bleveMapping.FieldMapping {
|
||||
fm := bleve.NewKeywordFieldMapping()
|
||||
switch {
|
||||
case opts.IncludeInAll != nil:
|
||||
fm.IncludeInAll = *opts.IncludeInAll
|
||||
case fieldType == TypePath:
|
||||
fm.IncludeInAll = false
|
||||
}
|
||||
return fm
|
||||
}
|
||||
|
||||
// lowercaseSibling derives the lowercased shadow of a keyword/path field from its
|
||||
// base mapping: used only for case-insensitive matching, so indexed but never
|
||||
// stored, kept out of _all, and without doc values, since the case-preserved base
|
||||
// field is what we return and aggregate on.
|
||||
func lowercaseSibling(base *bleveMapping.FieldMapping) *bleveMapping.FieldMapping {
|
||||
fm := *base
|
||||
fm.Store = false
|
||||
fm.IncludeInAll = false
|
||||
fm.DocValues = false
|
||||
return &fm
|
||||
}
|
||||
|
||||
func bleveFieldMapping(fieldType string, opts FieldOpts) (*bleveMapping.FieldMapping, error) {
|
||||
switch fieldType {
|
||||
case TypeWildcard:
|
||||
// bleve has no wildcard type; fall back to keyword-ish text.
|
||||
fieldType = TypeKeyword
|
||||
fallthrough
|
||||
case TypeKeyword, TypeFulltext:
|
||||
fm := bleve.NewTextFieldMapping()
|
||||
if fieldType == TypeFulltext {
|
||||
fm.Analyzer = "fulltext"
|
||||
}
|
||||
switch {
|
||||
case opts.IncludeInAll != nil:
|
||||
fm.IncludeInAll = *opts.IncludeInAll
|
||||
case fieldType == TypeFulltext:
|
||||
fm.IncludeInAll = false
|
||||
}
|
||||
return fm, nil
|
||||
case TypeNumeric:
|
||||
return bleve.NewNumericFieldMapping(), nil
|
||||
case TypeBool:
|
||||
return bleve.NewBooleanFieldMapping(), nil
|
||||
case TypeDatetime:
|
||||
return bleve.NewDateTimeFieldMapping(), nil
|
||||
case TypeGeopoint:
|
||||
return bleve.NewGeoPointFieldMapping(), nil
|
||||
case "":
|
||||
return nil, fmt.Errorf("no type inferred and no override")
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported type %q", fieldType)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type bleveDoc struct {
|
||||
Name string `json:"Name"`
|
||||
Content string `json:"Content"`
|
||||
Tags []string `json:"Tags"`
|
||||
Size uint64 `json:"Size"`
|
||||
Deleted bool `json:"Deleted"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
Nested *nested `json:"nested,omitempty"`
|
||||
}
|
||||
|
||||
type nested struct {
|
||||
Artist string `json:"artist"`
|
||||
Year int `json:"year"`
|
||||
}
|
||||
|
||||
var _ = Describe("BleveBuildMapping", func() {
|
||||
It("falls back to text for wildcard fields", func() {
|
||||
// bleve wildcard falls back to keyword-ish text (bleve has no wildcard type).
|
||||
type doc struct {
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"mime": {Type: TypeWildcard}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
fms := dm.Properties["mime"].Fields
|
||||
Expect(fms).To(HaveLen(1))
|
||||
Expect(fms[0].Type).To(Equal("text"), "wildcard should map to text")
|
||||
})
|
||||
|
||||
DescribeTable("infers field types",
|
||||
func(field, wantType string) {
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
prop := dm.Properties[field]
|
||||
Expect(prop).ToNot(BeNil(), "missing property %q", field)
|
||||
Expect(prop.Fields).ToNot(BeEmpty(), "%q: no field mappings", field)
|
||||
Expect(prop.Fields[0].Type).To(Equal(wantType), "%q type", field)
|
||||
},
|
||||
Entry("Name", "Name", "text"),
|
||||
Entry("Content", "Content", "text"),
|
||||
Entry("Tags", "Tags", "text"),
|
||||
Entry("Size", "Size", "number"),
|
||||
Entry("Deleted", "Deleted", "boolean"),
|
||||
Entry("CreatedAt", "CreatedAt", "datetime"),
|
||||
)
|
||||
|
||||
It("maps a nested struct as a sub-document", func() {
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sub := dm.Properties["nested"]
|
||||
Expect(sub).ToNot(BeNil(), "missing nested sub-document")
|
||||
Expect(sub.Properties["artist"]).ToNot(BeNil())
|
||||
Expect(sub.Properties["year"]).ToNot(BeNil())
|
||||
Expect(sub.Properties["artist"].Fields[0].Type).To(Equal("text"), "nested.artist")
|
||||
Expect(sub.Properties["year"].Fields[0].Type).To(Equal("number"), "nested.year")
|
||||
})
|
||||
|
||||
It("applies field overrides", func() {
|
||||
True, False := true, false
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{
|
||||
"Name": {CaseInsensitive: &True},
|
||||
"Content": {Type: TypeFulltext},
|
||||
"Tags": {CaseInsensitive: &True, IncludeInAll: &False},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Name: case-preserved base keyword + lowercased sibling.
|
||||
Expect(dm.Properties["Name"]).ToNot(BeNil(), "Name base field")
|
||||
Expect(dm.Properties["Name"].Fields[0].Analyzer).To(Equal("keyword"), "Name base is a keyword")
|
||||
Expect(dm.Properties["Name"].Fields[0].Store).To(BeTrue(), "Name base is stored (returned)")
|
||||
Expect(dm.Properties["Name_lowercase"]).ToNot(BeNil(), "Name_lowercase sibling")
|
||||
// The sibling is a search-only shadow: indexed but never stored, kept out
|
||||
// of _all, no doc values (the base is what we return).
|
||||
sibling := dm.Properties["Name_lowercase"].Fields[0]
|
||||
Expect(sibling.Index).To(BeTrue(), "Name_lowercase is indexed")
|
||||
Expect(sibling.Store).To(BeFalse(), "Name_lowercase is not stored")
|
||||
Expect(sibling.IncludeInAll).To(BeFalse(), "Name_lowercase is out of _all")
|
||||
Expect(sibling.DocValues).To(BeFalse(), "Name_lowercase has no doc values")
|
||||
contentField := dm.Properties["Content"].Fields[0]
|
||||
Expect(contentField.Analyzer).To(Equal("fulltext"), "Content analyzer")
|
||||
Expect(contentField.IncludeInAll).To(BeFalse(), "Content IncludeInAll should default to false for fulltext type")
|
||||
// Tags: base + lowercased sibling, both honoring the IncludeInAll override.
|
||||
Expect(dm.Properties["Tags"].Fields[0].IncludeInAll).To(BeFalse(), "Tags base IncludeInAll honored")
|
||||
Expect(dm.Properties["Tags_lowercase"].Fields[0].IncludeInAll).To(BeFalse(), "Tags sibling IncludeInAll honored")
|
||||
})
|
||||
|
||||
It("builds an object sub-document plus a geopoint sibling", func() {
|
||||
type geoDoc struct {
|
||||
Location *struct {
|
||||
Lon *float64 `json:"longitude,omitempty"`
|
||||
Lat *float64 `json:"latitude,omitempty"`
|
||||
Alt *float64 `json:"altitude,omitempty"`
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[geoDoc](), map[string]FieldOpts{
|
||||
"location": {Type: TypeGeopoint},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Original facet stays as an object sub-document with numeric
|
||||
// sub-properties - for data retrieval via hit.Fields and ordinary
|
||||
// numeric queries.
|
||||
loc := dm.Properties["location"]
|
||||
Expect(loc).ToNot(BeNil(), "location sub-document missing")
|
||||
Expect(loc.Fields).To(BeEmpty(), "location should not carry field mappings directly")
|
||||
for _, sub := range []string{"longitude", "latitude", "altitude"} {
|
||||
prop, ok := loc.Properties[sub]
|
||||
Expect(ok).To(BeTrue(), "missing sub-field %q under location", sub)
|
||||
Expect(prop.Fields).ToNot(BeEmpty(), "location.%s Fields", sub)
|
||||
Expect(prop.Fields[0].Type).To(Equal("number"), "location.%s type", sub)
|
||||
}
|
||||
// Sibling geopoint at "<name>_geopoint" for geo-distance queries.
|
||||
sibling := dm.Properties["location"+GeopointSuffix]
|
||||
Expect(sibling).ToNot(BeNil(), "location%s missing", GeopointSuffix)
|
||||
Expect(sibling.Fields).ToNot(BeEmpty(), "location%s Fields", GeopointSuffix)
|
||||
Expect(sibling.Fields[0].Type).To(Equal("geopoint"), "location%s type", GeopointSuffix)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
package mapping
|
||||
|
||||
import "strings"
|
||||
|
||||
func addLowercaseSiblings(m map[string]any, overrides map[string]FieldOpts) {
|
||||
for key, opts := range overrides {
|
||||
if !opts.caseInsensitive() || !isCasedType(opts) {
|
||||
continue
|
||||
}
|
||||
parent, leaf, ok := resolveLeaf(m, key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
addLowercaseSibling(parent, leaf)
|
||||
}
|
||||
}
|
||||
|
||||
func isCasedType(opts FieldOpts) bool {
|
||||
return opts.Type == "" || opts.Type == TypeKeyword || opts.Type == TypePath
|
||||
}
|
||||
|
||||
func resolveLeaf(m map[string]any, dottedPath string) (map[string]any, string, bool) {
|
||||
parts := strings.Split(dottedPath, ".")
|
||||
parent := m
|
||||
for _, p := range parts[:len(parts)-1] {
|
||||
next, ok := parent[p].(map[string]any)
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
parent = next
|
||||
}
|
||||
return parent, parts[len(parts)-1], true
|
||||
}
|
||||
|
||||
// addLowercaseSibling writes a <leaf>_lowercase sibling; no-op for non-strings.
|
||||
func addLowercaseSibling(parent map[string]any, leaf string) {
|
||||
switch v := parent[leaf].(type) {
|
||||
case string:
|
||||
parent[leaf+LowercaseSuffix] = strings.ToLower(v)
|
||||
case []any:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
parent[leaf+LowercaseSuffix] = out
|
||||
case []string:
|
||||
out := make([]string, len(v))
|
||||
for i, s := range v {
|
||||
out[i] = strings.ToLower(s)
|
||||
}
|
||||
parent[leaf+LowercaseSuffix] = out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PrepareForIndex casing", func() {
|
||||
It("adds lowercased siblings for CaseInsensitive keyword and path fields", func() {
|
||||
True := true
|
||||
type doc struct {
|
||||
Name string `json:"Name"`
|
||||
Path string `json:"Path"`
|
||||
Tags []string `json:"Tags"`
|
||||
}
|
||||
d := doc{Name: "Report FINAL", Path: "/Foo/Bar", Tags: []string{"Work", "Urgent"}}
|
||||
m, err := PrepareForIndex(d, map[string]FieldOpts{
|
||||
"Name": {CaseInsensitive: &True},
|
||||
"Path": {Type: TypePath, CaseInsensitive: &True},
|
||||
"Tags": {CaseInsensitive: &True},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Originals stay for the case-preserved base fields and the cascade.
|
||||
Expect(m["Name"]).To(Equal("Report FINAL"))
|
||||
Expect(m["Path"]).To(Equal("/Foo/Bar"))
|
||||
|
||||
Expect(m["Name_lowercase"]).To(Equal("report final"))
|
||||
Expect(m["Path_lowercase"]).To(Equal("/foo/bar"))
|
||||
Expect(m["Tags_lowercase"]).To(Equal([]any{"work", "urgent"}))
|
||||
})
|
||||
|
||||
It("writes no sibling without CaseInsensitive", func() {
|
||||
type doc struct {
|
||||
ID string `json:"ID"`
|
||||
}
|
||||
m, err := PrepareForIndex(doc{ID: "ABC"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).ToNot(HaveKey("ID" + LowercaseSuffix))
|
||||
})
|
||||
|
||||
It("writes an empty sibling for an empty array, like a non-empty one", func() {
|
||||
True := true
|
||||
type doc struct {
|
||||
Tags []string `json:"Tags"`
|
||||
}
|
||||
m, err := PrepareForIndex(doc{Tags: []string{}}, map[string]FieldOpts{
|
||||
"Tags": {CaseInsensitive: &True},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).To(HaveKey("Tags" + LowercaseSuffix))
|
||||
Expect(m["Tags"+LowercaseSuffix]).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrManualActionRequired marks schema changes that cannot be applied in place.
|
||||
var ErrManualActionRequired = errors.New("manual action required")
|
||||
|
||||
// ManualActionRequiredError reports a breaking schema change. Because the index
|
||||
// is versioned by search.SchemaVersion, a released instance never hits this: a
|
||||
// version bump builds a fresh index. It fires in development when the mapping is
|
||||
// changed in a breaking way without bumping search.SchemaVersion, so the fix is
|
||||
// to bump it (or revert the change).
|
||||
func ManualActionRequiredError(index string, reasons []string) error {
|
||||
return fmt.Errorf(
|
||||
"%w: the search mapping in code differs from index %s in a breaking way:\n - %s\n"+
|
||||
"bump search.SchemaVersion to build a fresh index, or revert the mapping change",
|
||||
ErrManualActionRequired, index, strings.Join(reasons, "\n - "),
|
||||
)
|
||||
}
|
||||
|
||||
type Verdict string
|
||||
|
||||
const (
|
||||
VerdictEqual Verdict = "equal"
|
||||
VerdictAdditive Verdict = "additive"
|
||||
VerdictBreaking Verdict = "breaking"
|
||||
)
|
||||
|
||||
// Classification is the outcome of diffing a stored index schema against the
|
||||
// schema generated from code.
|
||||
type Classification struct {
|
||||
Verdict Verdict
|
||||
// NewFields are dotted paths of fields that only exist in the code schema.
|
||||
NewFields []string
|
||||
// Reasons are human-readable breaking differences.
|
||||
Reasons []string
|
||||
}
|
||||
|
||||
// Classify recursively compares a stored `properties` tree against the one
|
||||
// generated from code. Both sides must be generic JSON-decoded values, not
|
||||
// marshaled Go structs. dataFields reports whether the index holds data at or
|
||||
// below a dotted field path absent from the stored schema (bleve dynamic
|
||||
// fields); engines without that blind spot pass nil.
|
||||
func Classify(stored, code map[string]any, dataFields func(path string) bool) Classification {
|
||||
c := Classification{Verdict: VerdictEqual}
|
||||
classifyProperties(stored, code, dataFields, "", &c)
|
||||
if c.Verdict == VerdictEqual && len(c.NewFields) > 0 {
|
||||
c.Verdict = VerdictAdditive
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func classifyProperties(stored, code map[string]any, dataFields func(string) bool, prefix string, c *Classification) {
|
||||
for _, k := range slices.Sorted(maps.Keys(stored)) {
|
||||
path := joinPath(prefix, k)
|
||||
codeNode, ok := code[k]
|
||||
if !ok {
|
||||
c.breaking(fmt.Sprintf("field %s exists in the index but not in the code schema (removed or renamed)", path))
|
||||
continue
|
||||
}
|
||||
classifyNode(stored[k], codeNode, dataFields, path, c)
|
||||
}
|
||||
|
||||
for _, k := range slices.Sorted(maps.Keys(code)) {
|
||||
if _, ok := stored[k]; ok {
|
||||
continue
|
||||
}
|
||||
path := joinPath(prefix, k)
|
||||
if dataFields != nil && dataFields(path) {
|
||||
c.breaking(fmt.Sprintf("field %s is explicitly mapped now, but the index already holds data that was indexed dynamically for it, of an unknown type", path))
|
||||
continue
|
||||
}
|
||||
c.NewFields = append(c.NewFields, leafPaths(code[k], path)...)
|
||||
}
|
||||
}
|
||||
|
||||
func classifyNode(stored, code any, dataFields func(string) bool, path string, c *Classification) {
|
||||
storedMap, sOK := stored.(map[string]any)
|
||||
codeMap, cOK := code.(map[string]any)
|
||||
if !sOK || !cOK {
|
||||
if !reflect.DeepEqual(stored, code) {
|
||||
c.breaking(fmt.Sprintf("field %s changed: index %s, code %s", path, compactJSON(stored), compactJSON(code)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, k := range sortedUnionKeys(storedMap, codeMap) {
|
||||
if k == "properties" {
|
||||
continue
|
||||
}
|
||||
sv, sHas := storedMap[k]
|
||||
cv, cHas := codeMap[k]
|
||||
if sHas && cHas && reflect.DeepEqual(sv, cv) {
|
||||
continue
|
||||
}
|
||||
c.breaking(fmt.Sprintf("field %s: %s changed: index %s, code %s", path, k, optJSON(sv, sHas), optJSON(cv, cHas)))
|
||||
}
|
||||
|
||||
storedProps, _ := storedMap["properties"].(map[string]any)
|
||||
codeProps, _ := codeMap["properties"].(map[string]any)
|
||||
if len(storedProps) > 0 || len(codeProps) > 0 {
|
||||
classifyProperties(storedProps, codeProps, dataFields, path, c)
|
||||
}
|
||||
}
|
||||
|
||||
// leafPaths lists the dotted paths of all leaf fields at or below node.
|
||||
func leafPaths(node any, path string) []string {
|
||||
if nodeMap, ok := node.(map[string]any); ok {
|
||||
if props, ok := nodeMap["properties"].(map[string]any); ok && len(props) > 0 {
|
||||
var leaves []string
|
||||
for _, k := range slices.Sorted(maps.Keys(props)) {
|
||||
leaves = append(leaves, leafPaths(props[k], path+"."+k)...)
|
||||
}
|
||||
return leaves
|
||||
}
|
||||
}
|
||||
return []string{path}
|
||||
}
|
||||
|
||||
func (c *Classification) breaking(reason string) {
|
||||
c.Verdict = VerdictBreaking
|
||||
c.Reasons = append(c.Reasons, reason)
|
||||
}
|
||||
|
||||
func joinPath(prefix, k string) string {
|
||||
if prefix == "" {
|
||||
return k
|
||||
}
|
||||
return prefix + "." + k
|
||||
}
|
||||
|
||||
func sortedUnionKeys(a, b map[string]any) []string {
|
||||
keys := slices.Collect(maps.Keys(a))
|
||||
for k := range b {
|
||||
if _, ok := a[k]; !ok {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func optJSON(v any, present bool) string {
|
||||
if !present {
|
||||
return "(unset)"
|
||||
}
|
||||
return compactJSON(v)
|
||||
}
|
||||
|
||||
func compactJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Classify", func() {
|
||||
code := `{
|
||||
"Name": {"type": "keyword"},
|
||||
"Size": {"type": "long"},
|
||||
"photo": {"properties": {"cameraMake": {"type": "keyword"}, "cameraModel": {"type": "keyword"}}}
|
||||
}`
|
||||
|
||||
parse := func(s string) map[string]any {
|
||||
var m map[string]any
|
||||
Expect(json.Unmarshal([]byte(s), &m)).To(Succeed())
|
||||
return m
|
||||
}
|
||||
|
||||
hasData := func(fields ...string) func(string) bool {
|
||||
return func(path string) bool { return slices.Contains(fields, path) }
|
||||
}
|
||||
|
||||
It("classifies identical schemas as equal", func() {
|
||||
c := Classify(parse(code), parse(code), nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictEqual))
|
||||
Expect(c.NewFields).To(BeEmpty())
|
||||
Expect(c.Reasons).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("classifies a new top-level field as additive", func() {
|
||||
stored := parse(code)
|
||||
delete(stored, "Size")
|
||||
|
||||
c := Classify(stored, parse(code), nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictAdditive))
|
||||
Expect(c.NewFields).To(ConsistOf("Size"))
|
||||
Expect(c.Reasons).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("classifies a new nested field as additive", func() {
|
||||
stored := parse(code)
|
||||
delete(stored["photo"].(map[string]any)["properties"].(map[string]any), "cameraMake")
|
||||
|
||||
c := Classify(stored, parse(code), nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictAdditive))
|
||||
Expect(c.NewFields).To(ConsistOf("photo.cameraMake"))
|
||||
})
|
||||
|
||||
It("lists every leaf of a new subtree", func() {
|
||||
stored := parse(code)
|
||||
delete(stored, "photo")
|
||||
|
||||
c := Classify(stored, parse(code), nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictAdditive))
|
||||
Expect(c.NewFields).To(ConsistOf("photo.cameraMake", "photo.cameraModel"))
|
||||
})
|
||||
|
||||
It("breaks when a new field already has data in the index", func() {
|
||||
stored := parse(code)
|
||||
delete(stored, "Size")
|
||||
|
||||
c := Classify(stored, parse(code), hasData("Size"))
|
||||
Expect(c.Verdict).To(Equal(VerdictBreaking))
|
||||
Expect(c.Reasons).To(ConsistOf(ContainSubstring("Size")))
|
||||
})
|
||||
|
||||
It("breaks when a new nested field already has data in the index", func() {
|
||||
stored := parse(code)
|
||||
delete(stored["photo"].(map[string]any)["properties"].(map[string]any), "cameraMake")
|
||||
|
||||
c := Classify(stored, parse(code), hasData("photo.cameraMake"))
|
||||
Expect(c.Verdict).To(Equal(VerdictBreaking))
|
||||
Expect(c.Reasons).To(ConsistOf(ContainSubstring("photo.cameraMake")))
|
||||
})
|
||||
|
||||
It("breaks when a new subtree already has data below it", func() {
|
||||
stored := parse(code)
|
||||
delete(stored, "photo")
|
||||
|
||||
// the callback is consulted with the subtree root
|
||||
c := Classify(stored, parse(code), hasData("photo"))
|
||||
Expect(c.Verdict).To(Equal(VerdictBreaking))
|
||||
Expect(c.Reasons).To(ConsistOf(ContainSubstring("photo")))
|
||||
})
|
||||
|
||||
It("breaks on a changed field definition", func() {
|
||||
stored := parse(code)
|
||||
stored["Size"].(map[string]any)["type"] = "keyword"
|
||||
|
||||
c := Classify(stored, parse(code), nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictBreaking))
|
||||
Expect(c.Reasons).To(ConsistOf(ContainSubstring("Size")))
|
||||
})
|
||||
|
||||
It("breaks on a field that was removed from the code schema", func() {
|
||||
reduced := parse(code)
|
||||
delete(reduced, "Size")
|
||||
|
||||
c := Classify(parse(code), reduced, nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictBreaking))
|
||||
Expect(c.Reasons).To(ConsistOf(ContainSubstring("removed or renamed")))
|
||||
})
|
||||
|
||||
It("breaks on a changed object attribute", func() {
|
||||
stored := parse(code)
|
||||
stored["photo"].(map[string]any)["dynamic"] = true
|
||||
|
||||
c := Classify(stored, parse(code), nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictBreaking))
|
||||
Expect(c.Reasons).To(ConsistOf(ContainSubstring("dynamic")))
|
||||
})
|
||||
|
||||
It("lets breaking win over additive", func() {
|
||||
stored := parse(code)
|
||||
delete(stored, "Size")
|
||||
stored["Name"].(map[string]any)["type"] = "text"
|
||||
|
||||
c := Classify(stored, parse(code), nil)
|
||||
Expect(c.Verdict).To(Equal(VerdictBreaking))
|
||||
Expect(c.NewFields).To(ConsistOf("Size"))
|
||||
Expect(c.Reasons).To(ConsistOf(ContainSubstring("Name")))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// Deserialize builds a *T from bleve's flat hit.Fields map (json-tag keys,
|
||||
// "parent.child" for nested pointers). Used to rebuild a search Resource from a
|
||||
// hit. Fail-soft: an unparseable field stays at its zero value.
|
||||
func Deserialize[T any](fields map[string]any) *T {
|
||||
t := reflect.TypeFor[T]()
|
||||
if t.Kind() != reflect.Struct {
|
||||
panic(fmt.Sprintf("mapping: Deserialize requires a struct type, got %v", t))
|
||||
}
|
||||
out := reflect.New(t)
|
||||
fillStruct(out.Elem(), fields, "", setValue)
|
||||
return out.Interface().(*T)
|
||||
}
|
||||
|
||||
// DeserializeAt is Deserialize scoped to a dotted prefix, used to rebuild one
|
||||
// search-result facet (e.g. "audio"). Returns nil when nothing matched, so the
|
||||
// caller can leave the enclosing pointer nil.
|
||||
func DeserializeAt[T any](fields map[string]any, prefix string) *T {
|
||||
t := reflect.TypeFor[T]()
|
||||
if t.Kind() != reflect.Struct {
|
||||
panic(fmt.Sprintf("mapping: DeserializeAt requires a struct type, got %v", t))
|
||||
}
|
||||
out := reflect.New(t)
|
||||
if !fillStruct(out.Elem(), fields, prefix, setValue) {
|
||||
return nil
|
||||
}
|
||||
return out.Interface().(*T)
|
||||
}
|
||||
|
||||
// fillStruct walks v's exported fields, reading values from the flat fields map
|
||||
// (json-tag keys, "parent.child" for nested pointers). setLeaf converts each raw
|
||||
// value into a leaf, which is what lets the any-valued and string-valued
|
||||
// deserializers share one walker. Returns true if any leaf was populated.
|
||||
func fillStruct[V any](v reflect.Value, fields map[string]V, prefix string, setLeaf func(reflect.Value, V) error) bool {
|
||||
t := v.Type()
|
||||
touched := false
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
fi := resolveField(t.Field(i))
|
||||
if fi.Skip {
|
||||
continue
|
||||
}
|
||||
fv := v.Field(i)
|
||||
|
||||
if fi.Embedded {
|
||||
// Embedded *struct: allocate, recurse, keep the pointer only if set.
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if fv.Type().Elem().Kind() != reflect.Struct || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
alloc := reflect.New(fv.Type().Elem())
|
||||
if fillStruct(alloc.Elem(), fields, prefix, setLeaf) {
|
||||
fv.Set(alloc)
|
||||
touched = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fv.Kind() == reflect.Struct && fillStruct(fv, fields, prefix, setLeaf) {
|
||||
touched = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
key := fi.Name
|
||||
if prefix != "" {
|
||||
key = prefix + "." + fi.Name
|
||||
}
|
||||
|
||||
// Pointer to a nested (non-time) struct: recurse, keep the pointer only
|
||||
// if a field was populated.
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if elem := fv.Type().Elem(); elem.Kind() == reflect.Struct && elem != timeType && elem != timestampType {
|
||||
alloc := reflect.New(elem)
|
||||
if fillStruct(alloc.Elem(), fields, key, setLeaf) {
|
||||
fv.Set(alloc)
|
||||
touched = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Value nested struct (e.g. a tagged embedded struct): recurse under key.
|
||||
if fv.Kind() == reflect.Struct && fv.Type() != timeType && fv.Type() != timestampType {
|
||||
if fillStruct(fv, fields, key, setLeaf) {
|
||||
touched = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if raw, ok := fields[key]; ok && setLeaf(fv, raw) == nil {
|
||||
touched = true
|
||||
}
|
||||
}
|
||||
return touched
|
||||
}
|
||||
|
||||
// setValue writes raw (an any value from a bleve hit) into v, converting to the
|
||||
// field's type. Returns an error on nil or a type mismatch.
|
||||
func setValue(v reflect.Value, raw any) error {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
alloc := reflect.New(v.Type().Elem())
|
||||
if err := setValue(alloc.Elem(), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
v.Set(alloc)
|
||||
return nil
|
||||
}
|
||||
if v.Type() == timeType || v.Type() == timestampType {
|
||||
t, ok := parseTime(raw)
|
||||
if !ok {
|
||||
return fmt.Errorf("not an RFC3339 time: %v", raw)
|
||||
}
|
||||
setParsedTime(v, t)
|
||||
return nil
|
||||
}
|
||||
if v.Kind() == reflect.Slice {
|
||||
return setSlice(v, raw)
|
||||
}
|
||||
rv := reflect.ValueOf(raw)
|
||||
if !rv.IsValid() {
|
||||
return fmt.Errorf("nil value for %s", v.Type())
|
||||
}
|
||||
if !rv.Type().ConvertibleTo(v.Type()) {
|
||||
return fmt.Errorf("cannot convert %s to %s", rv.Type(), v.Type())
|
||||
}
|
||||
v.Set(rv.Convert(v.Type()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func setSlice(v reflect.Value, raw any) error {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
// bleve unwraps single-element slices; re-wrap here.
|
||||
items = []any{raw}
|
||||
}
|
||||
// Compact in place with a single MakeSlice: unparseable elements are
|
||||
// dropped, Slice(0, j) trims the tail.
|
||||
out := reflect.MakeSlice(v.Type(), len(items), len(items))
|
||||
j := 0
|
||||
for _, item := range items {
|
||||
if setValue(out.Index(j), item) == nil {
|
||||
j++
|
||||
}
|
||||
}
|
||||
if j == 0 {
|
||||
return fmt.Errorf("no slice elements set from %T", raw)
|
||||
}
|
||||
v.Set(out.Slice(0, j))
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTime(raw any) (time.Time, bool) {
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// setParsedTime writes t into v, which must be a time.Time or
|
||||
// timestamppb.Timestamp field.
|
||||
func setParsedTime(v reflect.Value, t time.Time) {
|
||||
if v.Type() == timestampType {
|
||||
v.Set(reflect.ValueOf(*timestamppb.New(t)))
|
||||
return
|
||||
}
|
||||
v.Set(reflect.ValueOf(t))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeserializeStringsAt is DeserializeAt for a string-valued map (e.g. CS3
|
||||
// ArbitraryMetadata), parsing each string into the field's Go type via strconv/
|
||||
// time.Parse. Used to build a graph DriveItem facet. Returns nil when nothing
|
||||
// under the prefix matched.
|
||||
func DeserializeStringsAt[T any](fields map[string]string, prefix string) *T {
|
||||
t := reflect.TypeFor[T]()
|
||||
if t.Kind() != reflect.Struct {
|
||||
panic(fmt.Sprintf("mapping: DeserializeStringsAt requires a struct type, got %v", t))
|
||||
}
|
||||
out := reflect.New(t)
|
||||
// Callers pass a flat-key prefix with a trailing dot (e.g.
|
||||
// "libre.graph.audio."); fillStruct joins segments with ".", so drop it.
|
||||
if !fillStruct(out.Elem(), fields, strings.TrimSuffix(prefix, "."), setValueFromString) {
|
||||
return nil
|
||||
}
|
||||
return out.Interface().(*T)
|
||||
}
|
||||
|
||||
// setValueFromString parses the string raw into v's Go type via strconv/
|
||||
// time.Parse, returning a descriptive error on failure.
|
||||
func setValueFromString(v reflect.Value, raw string) error {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
alloc := reflect.New(v.Type().Elem())
|
||||
if err := setValueFromString(alloc.Elem(), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
v.Set(alloc)
|
||||
return nil
|
||||
}
|
||||
if v.Type() == timeType || v.Type() == timestampType {
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse time %q: %w", raw, err)
|
||||
}
|
||||
setParsedTime(v, t)
|
||||
return nil
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
v.SetString(raw)
|
||||
return nil
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse bool %q: %w", raw, err)
|
||||
}
|
||||
v.SetBool(b)
|
||||
return nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
n, err := strconv.ParseInt(raw, 10, v.Type().Bits())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse int %q: %w", raw, err)
|
||||
}
|
||||
v.SetInt(n)
|
||||
return nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
n, err := strconv.ParseUint(raw, 10, v.Type().Bits())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse uint %q: %w", raw, err)
|
||||
}
|
||||
v.SetUint(n)
|
||||
return nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
f, err := strconv.ParseFloat(raw, v.Type().Bits())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse float %q: %w", raw, err)
|
||||
}
|
||||
v.SetFloat(f)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unsupported target kind %s", v.Kind())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type stringFacet struct {
|
||||
Artist *string `json:"artist,omitempty"`
|
||||
Year *int32 `json:"year,omitempty"`
|
||||
Duration *int64 `json:"duration,omitempty"`
|
||||
Rating *float64 `json:"rating,omitempty"`
|
||||
Explicit *bool `json:"explicit,omitempty"`
|
||||
Taken *time.Time `json:"takenDateTime,omitempty"`
|
||||
}
|
||||
|
||||
var _ = Describe("DeserializeStringsAt", func() {
|
||||
It("errors on an unsupported target kind", func() {
|
||||
v := reflect.New(reflect.TypeFor[[]int]()).Elem() // settable slice
|
||||
Expect(setValueFromString(v, "x")).To(HaveOccurred(), "expected error for unsupported target kind (slice)")
|
||||
})
|
||||
|
||||
It("parses basic types", func() {
|
||||
r := DeserializeStringsAt[stringFacet](map[string]string{
|
||||
"libre.graph.audio.artist": "Queen",
|
||||
"libre.graph.audio.year": "1975",
|
||||
"libre.graph.audio.duration": "354000",
|
||||
"libre.graph.audio.rating": "4.9",
|
||||
"libre.graph.audio.explicit": "true",
|
||||
"libre.graph.audio.takenDateTime": "2024-01-02T03:04:05Z",
|
||||
}, "libre.graph.audio.")
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(r.Artist).ToNot(BeNil())
|
||||
Expect(*r.Artist).To(Equal("Queen"))
|
||||
Expect(r.Year).ToNot(BeNil())
|
||||
Expect(*r.Year).To(Equal(int32(1975)))
|
||||
Expect(r.Duration).ToNot(BeNil())
|
||||
Expect(*r.Duration).To(Equal(int64(354000)))
|
||||
Expect(r.Rating).ToNot(BeNil())
|
||||
Expect(*r.Rating).To(Equal(4.9))
|
||||
Expect(r.Explicit).ToNot(BeNil())
|
||||
Expect(*r.Explicit).To(BeTrue())
|
||||
Expect(r.Taken).ToNot(BeNil())
|
||||
Expect(r.Taken.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))).To(BeTrue(), "Taken: %#v", r.Taken)
|
||||
})
|
||||
|
||||
It("returns nil when nothing matches the prefix", func() {
|
||||
r := DeserializeStringsAt[stringFacet](map[string]string{
|
||||
"libre.graph.image.width": "1200",
|
||||
}, "libre.graph.audio.")
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("parses into a timestamppb.Timestamp", func() {
|
||||
type photoFacet struct {
|
||||
Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"`
|
||||
}
|
||||
r := DeserializeStringsAt[photoFacet](map[string]string{
|
||||
"libre.graph.photo.takenDateTime": "2024-05-06T07:08:09Z",
|
||||
}, "libre.graph.photo.")
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(r.Taken).ToNot(BeNil())
|
||||
want := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)
|
||||
Expect(r.Taken.AsTime().Equal(want)).To(BeTrue(), "Taken: got %v, want %v", r.Taken.AsTime(), want)
|
||||
})
|
||||
|
||||
It("is fail-soft on malformed fields", func() {
|
||||
// A single malformed field (year is unparseable as int) must not drop
|
||||
// the whole facet. The bad field stays at zero value, the rest of the
|
||||
// facet still populates. Mirrors the bleve-hit Deserialize behavior.
|
||||
r := DeserializeStringsAt[stringFacet](map[string]string{
|
||||
"libre.graph.audio.artist": "Iron Maiden",
|
||||
"libre.graph.audio.year": "not-a-number",
|
||||
"libre.graph.audio.duration": "354000",
|
||||
"libre.graph.audio.explicit": "not-a-bool",
|
||||
"libre.graph.audio.rating": "4.9",
|
||||
}, "libre.graph.audio.")
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(r.Artist).ToNot(BeNil())
|
||||
Expect(*r.Artist).To(Equal("Iron Maiden"), "Artist should still be populated")
|
||||
Expect(r.Duration).ToNot(BeNil())
|
||||
Expect(*r.Duration).To(Equal(int64(354000)), "Duration should still be populated")
|
||||
Expect(r.Rating).ToNot(BeNil())
|
||||
Expect(*r.Rating).To(Equal(4.9), "Rating should still be populated")
|
||||
Expect(r.Year).To(BeNil(), "Year should stay nil for bad int")
|
||||
Expect(r.Explicit).To(BeNil(), "Explicit should stay nil for bad bool")
|
||||
})
|
||||
|
||||
It("panics for a non-struct T", func() {
|
||||
Expect(func() {
|
||||
DeserializeStringsAt[int](nil, "")
|
||||
}).To(Panic())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type Leaf struct {
|
||||
Name string `json:"Name"`
|
||||
Size uint64 `json:"Size"`
|
||||
Deleted bool `json:"Deleted"`
|
||||
Tags []string `json:"Tags"`
|
||||
Favorites []string `json:"Favorites"`
|
||||
}
|
||||
|
||||
type audio struct {
|
||||
Artist *string `json:"artist,omitempty"`
|
||||
Year *int32 `json:"year,omitempty"`
|
||||
}
|
||||
|
||||
type photo struct {
|
||||
Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"`
|
||||
Mtime *time.Time `json:"mtime,omitempty"`
|
||||
}
|
||||
|
||||
type embedded struct {
|
||||
Leaf
|
||||
Audio *audio `json:"audio,omitempty"`
|
||||
Photo *photo `json:"photo,omitempty"`
|
||||
}
|
||||
|
||||
// taggedEmbedded embeds Leaf with a json tag, so encoding/json nests it under
|
||||
// "leaf" rather than flattening it onto the parent.
|
||||
type taggedEmbedded struct {
|
||||
Leaf `json:"leaf"`
|
||||
Top string `json:"top"`
|
||||
}
|
||||
|
||||
var _ = Describe("Deserialize", func() {
|
||||
It("panics for a non-struct type at a prefix", func() {
|
||||
Expect(func() {
|
||||
_ = DeserializeAt[int](map[string]any{}, "")
|
||||
}).To(Panic())
|
||||
})
|
||||
|
||||
It("panics for a non-struct T", func() {
|
||||
Expect(func() {
|
||||
Deserialize[int](nil)
|
||||
}).To(Panic())
|
||||
})
|
||||
|
||||
It("deserializes leaf fields", func() {
|
||||
r := Deserialize[Leaf](map[string]any{
|
||||
"Name": "n",
|
||||
"Size": float64(42),
|
||||
"Deleted": true,
|
||||
})
|
||||
Expect(r.Name).To(Equal("n"))
|
||||
Expect(r.Size).To(Equal(uint64(42)))
|
||||
Expect(r.Deleted).To(BeTrue())
|
||||
})
|
||||
|
||||
It("coerces a scalar into a slice field", func() {
|
||||
r := Deserialize[Leaf](map[string]any{
|
||||
"Tags": "single",
|
||||
"Favorites": []any{"a", "b"},
|
||||
})
|
||||
Expect(r.Tags).To(Equal([]string{"single"}))
|
||||
Expect(r.Favorites).To(Equal([]string{"a", "b"}))
|
||||
})
|
||||
|
||||
It("parses timestamps", func() {
|
||||
r := Deserialize[embedded](map[string]any{
|
||||
"photo.takenDateTime": "2024-01-02T03:04:05Z",
|
||||
"photo.mtime": "2024-05-06T07:08:09Z",
|
||||
})
|
||||
Expect(r.Photo).ToNot(BeNil())
|
||||
Expect(r.Photo.Taken).ToNot(BeNil())
|
||||
expected := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
Expect(r.Photo.Taken.AsTime().Equal(expected)).To(BeTrue(), "Taken: got %v, want %v", r.Photo.Taken.AsTime(), expected)
|
||||
Expect(r.Photo.Mtime).ToNot(BeNil())
|
||||
Expect(r.Photo.Mtime.Equal(time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC))).To(BeTrue(), "Mtime: %v", r.Photo.Mtime)
|
||||
})
|
||||
|
||||
It("is fail-soft on malformed values", func() {
|
||||
// Malformed values (type mismatch, unparseable time) leave the
|
||||
// affected field at its zero value instead of dropping the whole
|
||||
// record. Matches the pre-refactor getFieldValue behavior so
|
||||
// matchToResource never returns nil on a corrupted hit.
|
||||
r := Deserialize[embedded](map[string]any{
|
||||
"Name": "n",
|
||||
"Size": "not-a-number", // wrong type
|
||||
"Deleted": true,
|
||||
"photo.takenDateTime": "not-an-rfc3339-time",
|
||||
"photo.mtime": "2024-05-06T07:08:09Z",
|
||||
})
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(r.Name).To(Equal("n"))
|
||||
Expect(r.Size).To(Equal(uint64(0)), "Size should stay zero on mismatch")
|
||||
Expect(r.Deleted).To(BeTrue())
|
||||
Expect(r.Photo).ToNot(BeNil(), "Photo should be populated because Mtime parsed ok")
|
||||
Expect(r.Photo.Taken).To(BeNil(), "Taken should stay nil for unparseable time")
|
||||
Expect(r.Photo.Mtime).ToNot(BeNil(), "Mtime should be parsed")
|
||||
})
|
||||
|
||||
It("returns nil when nothing matches the prefix", func() {
|
||||
r := DeserializeAt[audio](map[string]any{"Name": "n"}, "audio")
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns a value when the prefix matches", func() {
|
||||
r := DeserializeAt[audio](map[string]any{
|
||||
"audio.artist": "A",
|
||||
"audio.year": float64(2024), // setValue: pointer + numeric convert
|
||||
}, "audio")
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(r.Artist).ToNot(BeNil())
|
||||
Expect(*r.Artist).To(Equal("A"))
|
||||
Expect(r.Year).ToNot(BeNil())
|
||||
Expect(*r.Year).To(Equal(int32(2024)))
|
||||
})
|
||||
|
||||
It("nests a tagged embedded struct instead of flattening it", func() {
|
||||
// matches encoding/json: a tagged embedded struct is read under its tag.
|
||||
r := Deserialize[taggedEmbedded](map[string]any{
|
||||
"leaf.Name": "n",
|
||||
"top": "t",
|
||||
})
|
||||
Expect(r.Leaf.Name).To(Equal("n"))
|
||||
Expect(r.Top).To(Equal("t"))
|
||||
|
||||
// the flattened top-level key must NOT populate the nested field.
|
||||
flat := Deserialize[taggedEmbedded](map[string]any{"Name": "flat"})
|
||||
Expect(flat.Leaf.Name).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FieldNameIndex maps a lowercased field path to the real field name for every
|
||||
// field of t, recursing into nested facets (photo.cameraMake, ...). Names come
|
||||
// from json tags, so it is backend-neutral; the query layer resolves KQL keys
|
||||
// case-insensitively against it.
|
||||
func FieldNameIndex(t reflect.Type, overrides map[string]FieldOpts) map[string]string {
|
||||
out := map[string]string{}
|
||||
var walk func(t reflect.Type, prefix string)
|
||||
walk = func(t reflect.Type, prefix string) {
|
||||
_ = walkFields(t, func(fi fieldInfo) error {
|
||||
name := fi.Name
|
||||
if prefix != "" {
|
||||
name = prefix + "." + fi.Name
|
||||
}
|
||||
out[strings.ToLower(name)] = name
|
||||
|
||||
fieldType := overrides[name].Type
|
||||
if fieldType == "" {
|
||||
fieldType = inferType(fi.GoField.Type)
|
||||
}
|
||||
// recurse into nested facets; time.Time is a struct too but a leaf.
|
||||
if sub := structType(fi.GoField.Type); sub != nil && fieldType != TypeDatetime {
|
||||
walk(sub, name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
walk(t, "")
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package mapping_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
func resourceFieldIndex() map[string]string {
|
||||
return mapping.FieldNameIndex(
|
||||
reflect.TypeFor[search.Resource](),
|
||||
search.Resource{}.SearchFieldOverrides(),
|
||||
)
|
||||
}
|
||||
|
||||
// resolve looks up the lowercased key in the index, falling back to the key.
|
||||
func resolve(idx map[string]string, key string) string {
|
||||
if v, ok := idx[strings.ToLower(key)]; ok {
|
||||
return v
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// NestInner is embedded with a json tag below, so it must nest, not flatten.
|
||||
type NestInner struct {
|
||||
A string `json:"A"`
|
||||
}
|
||||
|
||||
type taggedOuter struct {
|
||||
NestInner `json:"inner"`
|
||||
Top string `json:"top"`
|
||||
}
|
||||
|
||||
var _ = Describe("FieldNameIndex", func() {
|
||||
It("resolves top-level fields case-insensitively", func() {
|
||||
idx := resourceFieldIndex()
|
||||
for in, want := range map[string]string{
|
||||
"rootid": "RootID", "ROOTID": "RootID", "RootID": "RootID",
|
||||
"name": "Name", "NAME": "Name",
|
||||
"mimetype": "MimeType", "MimeType": "MimeType",
|
||||
"tags": "Tags", "favorites": "Favorites",
|
||||
"mtime": "Mtime", "parentid": "ParentID", "id": "ID",
|
||||
} {
|
||||
Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in)
|
||||
}
|
||||
})
|
||||
|
||||
// Facet sub-fields are lowerCamelCase in the index (from libregraph json
|
||||
// tags); the derived index resolves them case-insensitively.
|
||||
It("resolves facet sub-fields case-insensitively", func() {
|
||||
idx := resourceFieldIndex()
|
||||
for in, want := range map[string]string{
|
||||
// case-insensitive: same field, different casings
|
||||
"photo.cameramake": "photo.cameraMake",
|
||||
"photo.CAMERAMAKE": "photo.cameraMake",
|
||||
// a representative sub-field across each facet
|
||||
"photo.takendatetime": "photo.takenDateTime",
|
||||
"audio.artist": "audio.artist",
|
||||
"audio.albumartist": "audio.albumArtist",
|
||||
"image.width": "image.width",
|
||||
"location.latitude": "location.latitude",
|
||||
} {
|
||||
Expect(resolve(idx, in)).To(Equal(want), "resolve(%q)", in)
|
||||
}
|
||||
})
|
||||
|
||||
It("passes unknown keys through unchanged", func() {
|
||||
idx := resourceFieldIndex()
|
||||
Expect(resolve(idx, "nope.field")).To(Equal("nope.field"))
|
||||
Expect(resolve(idx, "custom")).To(Equal("custom"))
|
||||
})
|
||||
|
||||
// All top-level fields are covered from one derived source, so both
|
||||
// backends resolve them the same way.
|
||||
It("covers all top-level fields", func() {
|
||||
idx := resourceFieldIndex()
|
||||
for in, want := range map[string]string{
|
||||
"rootid": "RootID", "path": "Path", "id": "ID", "name": "Name",
|
||||
"size": "Size", "mtime": "Mtime", "type": "Type",
|
||||
"content": "Content", "hidden": "Hidden", "tags": "Tags",
|
||||
"favorites": "Favorites",
|
||||
} {
|
||||
Expect(resolve(idx, in)).To(Equal(want), "derived should cover %q", in)
|
||||
}
|
||||
})
|
||||
|
||||
// A json-tagged embedded struct nests under its tag in the derived index
|
||||
// too (walkFields must match encoding/json), so its fields are "inner.A",
|
||||
// not "A".
|
||||
It("nests json-tagged embedded structs", func() {
|
||||
idx := mapping.FieldNameIndex(reflect.TypeFor[taggedOuter](), nil)
|
||||
Expect(resolve(idx, "inner.a")).To(Equal("inner.A")) // nested under the tag
|
||||
Expect(resolve(idx, "top")).To(Equal("top"))
|
||||
Expect(resolve(idx, "a")).To(Equal("a")) // not flattened: bare "a" is not a key
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The walker is shared by both deserializers, so its structural behavior
|
||||
// (flattening embedded structs, recursing into nested pointers, joining the
|
||||
// prefix, keeping a pointer only when a field was set, fail-soft skipping) is
|
||||
// tested here once against a trivial string setter instead of twice through
|
||||
// Deserialize and DeserializeStringsAt.
|
||||
|
||||
// FsEmbVal/FsEmbPtr are exported so their embedded field is exported; an
|
||||
// unexported embedded type would be skipped by resolveField.
|
||||
type FsEmbVal struct {
|
||||
EV string `json:"ev"`
|
||||
}
|
||||
|
||||
type FsEmbPtr struct {
|
||||
EP string `json:"ep"`
|
||||
}
|
||||
|
||||
type fsNested struct {
|
||||
N string `json:"n"`
|
||||
}
|
||||
|
||||
type fsRoot struct {
|
||||
FsEmbVal // embedded value struct: fields promoted
|
||||
*FsEmbPtr // embedded pointer struct: allocated on demand
|
||||
Leaf string `json:"leaf"`
|
||||
Nested *fsNested `json:"nested"` // nested pointer: recursed under "nested."
|
||||
}
|
||||
|
||||
var errBadLeaf = errors.New("bad leaf")
|
||||
|
||||
// fsSet writes raw into a string field; the "BAD" sentinel simulates a parse
|
||||
// failure so the fail-soft path can be exercised.
|
||||
func fsSet(v reflect.Value, raw string) error {
|
||||
if raw == "BAD" {
|
||||
return errBadLeaf
|
||||
}
|
||||
v.SetString(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = Describe("fillStruct", func() {
|
||||
fill := func(fields map[string]string, prefix string) (fsRoot, bool) {
|
||||
var root fsRoot
|
||||
touched := fillStruct(reflect.ValueOf(&root).Elem(), fields, prefix, fsSet)
|
||||
return root, touched
|
||||
}
|
||||
|
||||
It("flattens embedded and recurses nested", func() {
|
||||
root, touched := fill(map[string]string{
|
||||
"leaf": "L",
|
||||
"ev": "EV",
|
||||
"ep": "EP",
|
||||
"nested.n": "N",
|
||||
}, "")
|
||||
Expect(touched).To(BeTrue())
|
||||
Expect(root.Leaf).To(Equal("L"))
|
||||
Expect(root.EV).To(Equal("EV"), "embedded value not promoted")
|
||||
Expect(root.FsEmbPtr).ToNot(BeNil(), "embedded pointer not allocated")
|
||||
Expect(root.EP).To(Equal("EP"))
|
||||
Expect(root.Nested).ToNot(BeNil(), "nested pointer not populated")
|
||||
Expect(root.Nested.N).To(Equal("N"))
|
||||
})
|
||||
|
||||
It("leaves touched false and pointers nil when nothing matches", func() {
|
||||
root, touched := fill(map[string]string{"other": "x"}, "")
|
||||
Expect(touched).To(BeFalse())
|
||||
Expect(root.Nested).To(BeNil(), "Nested should stay nil")
|
||||
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil")
|
||||
})
|
||||
|
||||
It("joins the prefix arg with the field name", func() {
|
||||
root, touched := fill(map[string]string{
|
||||
"pre.leaf": "L",
|
||||
"pre.nested.n": "N",
|
||||
}, "pre")
|
||||
Expect(touched).To(BeTrue())
|
||||
Expect(root.Leaf).To(Equal("L"))
|
||||
Expect(root.Nested).ToNot(BeNil())
|
||||
Expect(root.Nested.N).To(Equal("N"))
|
||||
})
|
||||
|
||||
It("is fail-soft: errored leaf stays zero, walk continues", func() {
|
||||
root, touched := fill(map[string]string{
|
||||
"leaf": "BAD",
|
||||
"ev": "EV",
|
||||
}, "")
|
||||
Expect(touched).To(BeTrue(), "expected touched because ev was set")
|
||||
Expect(root.Leaf).To(BeEmpty(), "errored leaf should stay zero")
|
||||
Expect(root.EV).To(Equal("EV"), "walk should continue past the error")
|
||||
})
|
||||
|
||||
It("drops the embedded pointer when its only field errors", func() {
|
||||
root, touched := fill(map[string]string{"ep": "BAD"}, "")
|
||||
Expect(touched).To(BeFalse())
|
||||
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil on error")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
package mapping
|
||||
|
||||
import "strings"
|
||||
|
||||
// GeopointSuffix is appended to a field's name to produce the sibling key
|
||||
// that carries the geo_point / bleve-geopoint representation of the
|
||||
// original facet. For example, a libregraph "location" object with
|
||||
// longitude / latitude / altitude is preserved as-is under "location" (for
|
||||
// data retrieval and numeric queries) while "location_geopoint" carries
|
||||
// the {lat, lon} form the geo indices understand.
|
||||
const GeopointSuffix = "_geopoint"
|
||||
|
||||
// addGeopointSiblings walks the overrides; for each TypeGeopoint entry at
|
||||
// a dotted path (e.g. "location" or "journey.start") it writes a sibling
|
||||
// under the suffixed key with the {lat, lon} form both bleve's
|
||||
// ExtractGeoPoint and OpenSearch's geo_point parser accept. The original
|
||||
// facet object stays untouched so downstream code still sees the full
|
||||
// libregraph shape (including altitude).
|
||||
func addGeopointSiblings(m map[string]any, overrides map[string]FieldOpts) {
|
||||
for key, opts := range overrides {
|
||||
if opts.Type == TypeGeopoint {
|
||||
addGeopointSibling(m, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addGeopointSibling resolves dottedPath within m and, if the target is a
|
||||
// libregraph-shaped geo object (with numeric "longitude" and "latitude"),
|
||||
// writes the `{lat, lon}` sibling at the same level under the suffixed key.
|
||||
func addGeopointSibling(m map[string]any, dottedPath string) {
|
||||
parts := strings.Split(dottedPath, ".")
|
||||
parent := m
|
||||
for _, p := range parts[:len(parts)-1] {
|
||||
next, ok := parent[p].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
parent = next
|
||||
}
|
||||
leaf := parts[len(parts)-1]
|
||||
obj, ok := parent[leaf].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
lon, hasLon := obj["longitude"].(float64)
|
||||
lat, hasLat := obj["latitude"].(float64)
|
||||
if !hasLon || !hasLat {
|
||||
return
|
||||
}
|
||||
parent[leaf+GeopointSuffix] = map[string]any{"lat": lat, "lon": lon}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("BuildMapping geopoint errors", func() {
|
||||
// build-mapping error paths: an override that doesn't fit the Go field.
|
||||
It("errors for geopoint on a non-struct field on both backends", func() {
|
||||
type doc struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
// Geopoint on a non-struct field must error on both backends.
|
||||
_, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}})
|
||||
Expect(err).To(HaveOccurred(), "bleve: expected error for geopoint on string field")
|
||||
_, err = OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}})
|
||||
Expect(err).To(HaveOccurred(), "opensearch: expected error for geopoint on string field")
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("addGeopointSibling", func() {
|
||||
It("bails without panicking when the intermediate is missing", func() {
|
||||
m := map[string]any{"journey": "not-a-map"}
|
||||
addGeopointSibling(m, "journey.start") // must bail, not panic
|
||||
Expect(m).ToNot(HaveKey("journey.start" + GeopointSuffix))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("PrepareForIndex geopoint", func() {
|
||||
It("adds a geopoint sibling", func() {
|
||||
type geoDoc struct {
|
||||
Location *struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Altitude *float64 `json:"altitude,omitempty"`
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
lon, lat, alt := 11.1, 49.4, 1047.7
|
||||
doc := geoDoc{Location: &struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Altitude *float64 `json:"altitude,omitempty"`
|
||||
}{Longitude: &lon, Latitude: &lat, Altitude: &alt}}
|
||||
|
||||
m, err := PrepareForIndex(doc, map[string]FieldOpts{
|
||||
"location": {Type: TypeGeopoint},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Original location object stays untouched (full libregraph shape).
|
||||
orig, ok := m["location"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "expected location object preserved, got %T", m["location"])
|
||||
Expect(orig["longitude"]).To(Equal(lon))
|
||||
Expect(orig["latitude"]).To(Equal(lat))
|
||||
Expect(orig["altitude"]).To(Equal(alt))
|
||||
|
||||
// Sibling location_geopoint has {lat, lon} for the geo indices.
|
||||
gp, ok := m["location"+GeopointSuffix].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "expected location_geopoint sibling, got %T", m["location"+GeopointSuffix])
|
||||
Expect(gp["lat"]).To(Equal(lat))
|
||||
Expect(gp["lon"]).To(Equal(lon))
|
||||
})
|
||||
|
||||
It("skips incomplete geopoints", func() {
|
||||
type geoDoc struct {
|
||||
Location *struct {
|
||||
Altitude *float64 `json:"altitude,omitempty"`
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
alt := 100.0
|
||||
doc := geoDoc{Location: &struct {
|
||||
Altitude *float64 `json:"altitude,omitempty"`
|
||||
}{Altitude: &alt}}
|
||||
|
||||
m, err := PrepareForIndex(doc, map[string]FieldOpts{
|
||||
"location": {Type: TypeGeopoint},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Original stays (altitude alone is still useful metadata).
|
||||
Expect(m).To(HaveKey("location"), "location should still be present when only altitude is set")
|
||||
// No sibling without both lon and lat.
|
||||
Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected")
|
||||
})
|
||||
|
||||
It("writes no sibling without the geopoint override", func() {
|
||||
type geoDoc struct {
|
||||
Location *struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
lon, lat := 11.1, 49.4
|
||||
doc := geoDoc{Location: &struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
}{Longitude: &lon, Latitude: &lat}}
|
||||
|
||||
m, err := PrepareForIndex(doc, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected without override")
|
||||
})
|
||||
|
||||
It("handles nested geopoints via the dotted-path walker", func() {
|
||||
// journey.start and journey.end - two geopoints in the same facet,
|
||||
// demonstrating the dotted-path walker.
|
||||
type geo struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
}
|
||||
type journey struct {
|
||||
Start *geo `json:"start,omitempty"`
|
||||
End *geo `json:"end,omitempty"`
|
||||
}
|
||||
type doc struct {
|
||||
Journey *journey `json:"journey,omitempty"`
|
||||
}
|
||||
slon, slat := 11.0, 49.0
|
||||
elon, elat := 13.4, 52.5
|
||||
d := doc{Journey: &journey{
|
||||
Start: &geo{Longitude: &slon, Latitude: &slat},
|
||||
End: &geo{Longitude: &elon, Latitude: &elat},
|
||||
}}
|
||||
|
||||
m, err := PrepareForIndex(d, map[string]FieldOpts{
|
||||
"journey.start": {Type: TypeGeopoint},
|
||||
"journey.end": {Type: TypeGeopoint},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
j, ok := m["journey"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "journey not an object: %T", m["journey"])
|
||||
startGp, ok := j["start"+GeopointSuffix].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "journey.start sibling: %#v", j["start"+GeopointSuffix])
|
||||
Expect(startGp["lat"]).To(Equal(slat))
|
||||
Expect(startGp["lon"]).To(Equal(slon))
|
||||
endGp, ok := j["end"+GeopointSuffix].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "journey.end sibling: %#v", j["end"+GeopointSuffix])
|
||||
Expect(endGp["lat"]).To(Equal(elat))
|
||||
Expect(endGp["lon"]).To(Equal(elon))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
var (
|
||||
timeType = reflect.TypeFor[time.Time]()
|
||||
timestampType = reflect.TypeFor[timestamppb.Timestamp]()
|
||||
)
|
||||
|
||||
// deref unwraps pointer and slice types to their element type.
|
||||
func deref(t reflect.Type) reflect.Type {
|
||||
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// inferType returns the mapping type for a Go type. Pointers and slices are
|
||||
// unwrapped to their element type. time.Time and timestamppb.Timestamp become
|
||||
// datetime; other structs become object.
|
||||
func inferType(t reflect.Type) string {
|
||||
t = deref(t)
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return TypeKeyword
|
||||
case reflect.Bool:
|
||||
return TypeBool
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||
reflect.Float32, reflect.Float64:
|
||||
return TypeNumeric
|
||||
case reflect.Struct:
|
||||
if t == timeType || t == timestampType {
|
||||
return TypeDatetime
|
||||
}
|
||||
return TypeObject
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fieldInfo is the resolved metadata for one struct field.
|
||||
type fieldInfo struct {
|
||||
Name string
|
||||
GoField reflect.StructField
|
||||
Skip bool
|
||||
Embedded bool
|
||||
}
|
||||
|
||||
// resolveField resolves a struct field's json-tag name and skip/embed state.
|
||||
func resolveField(sf reflect.StructField) fieldInfo {
|
||||
if !sf.IsExported() {
|
||||
return fieldInfo{Skip: true}
|
||||
}
|
||||
name := sf.Name
|
||||
named := false
|
||||
tag := sf.Tag.Get("json")
|
||||
if tag != "" {
|
||||
first, _, _ := strings.Cut(tag, ",")
|
||||
if first == "-" {
|
||||
return fieldInfo{Skip: true}
|
||||
}
|
||||
if first != "" {
|
||||
name = first
|
||||
named = true
|
||||
}
|
||||
}
|
||||
return fieldInfo{
|
||||
Name: name,
|
||||
GoField: sf,
|
||||
// An anonymous field is embedded (flattened onto the parent) only when it
|
||||
// has no json tag name, matching encoding/json: a tag name nests it as a
|
||||
// regular field instead.
|
||||
Embedded: sf.Anonymous && !named,
|
||||
}
|
||||
}
|
||||
|
||||
// walkFields visits exported leaf fields of t, flattening embedded structs
|
||||
// onto the enclosing level. It returns the first error returned by fn.
|
||||
func walkFields(t reflect.Type, fn func(fi fieldInfo) error) error {
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
fi := resolveField(t.Field(i))
|
||||
if fi.Skip {
|
||||
continue
|
||||
}
|
||||
if fi.Embedded {
|
||||
if err := walkFields(fi.GoField.Type, fn); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := fn(fi); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// structType returns the underlying struct type, unwrapping pointers and
|
||||
// slices. Returns nil when t is not a walkable struct (e.g. time.Time).
|
||||
func structType(t reflect.Type) reflect.Type {
|
||||
t = deref(t)
|
||||
if t.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
if t == timeType || t == timestampType {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
var _ = Describe("inferType", func() {
|
||||
It("returns empty for unsupported kinds", func() {
|
||||
Expect(inferType(reflect.TypeFor[map[string]int]())).To(BeEmpty(), "map")
|
||||
Expect(inferType(reflect.TypeFor[chan int]())).To(BeEmpty(), "chan")
|
||||
})
|
||||
|
||||
DescribeTable("infers the mapping type",
|
||||
func(in any, want string) {
|
||||
Expect(inferType(reflect.TypeOf(in))).To(Equal(want))
|
||||
},
|
||||
Entry("string", "", TypeKeyword),
|
||||
Entry("*string", (*string)(nil), TypeKeyword),
|
||||
Entry("[]string", []string(nil), TypeKeyword),
|
||||
Entry("bool", false, TypeBool),
|
||||
Entry("int", int(0), TypeNumeric),
|
||||
Entry("int64", int64(0), TypeNumeric),
|
||||
Entry("uint64", uint64(0), TypeNumeric),
|
||||
Entry("float64", float64(0), TypeNumeric),
|
||||
Entry("time.Time", time.Time{}, TypeDatetime),
|
||||
Entry("*time.Time", (*time.Time)(nil), TypeDatetime),
|
||||
Entry("*timestamppb.Timestamp", (*timestamppb.Timestamp)(nil), TypeDatetime),
|
||||
Entry("struct", struct{ X int }{}, TypeObject),
|
||||
Entry("*struct", (*struct{ X int })(nil), TypeObject),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("resolveField", func() {
|
||||
type S struct {
|
||||
Exported string `json:"exp"`
|
||||
Renamed string `json:"renamed,omitempty"`
|
||||
NoTag string
|
||||
OmitOnly string `json:",omitempty"`
|
||||
Skipped string `json:"-"`
|
||||
unexported string //nolint:unused
|
||||
}
|
||||
st := reflect.TypeFor[S]()
|
||||
|
||||
DescribeTable("resolves the field name and skip flag",
|
||||
func(fieldIdx int, wantName string, wantSkip bool) {
|
||||
fi := resolveField(st.Field(fieldIdx))
|
||||
Expect(fi.Skip).To(Equal(wantSkip), "field %d skip", fieldIdx)
|
||||
if !wantSkip {
|
||||
Expect(fi.Name).To(Equal(wantName), "field %d name", fieldIdx)
|
||||
}
|
||||
},
|
||||
Entry("exported json tag", 0, "exp", false),
|
||||
Entry("renamed with omitempty", 1, "renamed", false),
|
||||
Entry("no tag", 2, "NoTag", false),
|
||||
Entry("omitempty only", 3, "OmitOnly", false),
|
||||
Entry("json:- skipped", 4, "", true),
|
||||
Entry("unexported skipped", 5, "", true),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("walkFields", func() {
|
||||
It("flattens embedded structs", func() {
|
||||
type Inner struct {
|
||||
A string `json:"a"`
|
||||
B int `json:"b"`
|
||||
}
|
||||
type Outer struct {
|
||||
Inner
|
||||
C bool `json:"c"`
|
||||
}
|
||||
var names []string
|
||||
err := walkFields(reflect.TypeFor[Outer](), func(fi fieldInfo) error {
|
||||
names = append(names, fi.Name)
|
||||
return nil
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(names).To(Equal([]string{"a", "b", "c"}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("structType", func() {
|
||||
type S struct{ X int }
|
||||
|
||||
DescribeTable("resolves struct-ish types",
|
||||
func(in reflect.Type, wantNil bool) {
|
||||
got := structType(in)
|
||||
Expect(got == nil).To(Equal(wantNil))
|
||||
},
|
||||
Entry("struct", reflect.TypeFor[S](), false),
|
||||
Entry("*struct", reflect.TypeFor[*S](), false),
|
||||
Entry("[]struct", reflect.TypeFor[[]S](), false),
|
||||
Entry("time.Time", reflect.TypeFor[time.Time](), true),
|
||||
Entry("string", reflect.TypeFor[string](), true),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMapping(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Mapping Suite")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// OpenSearchBuildMapping builds the OpenSearch "properties" map (the value
|
||||
// of mappings.properties) for type t by walking the struct via reflection.
|
||||
// Field names come from json tags; overrides are keyed by those names.
|
||||
//
|
||||
// The returned map contains plain JSON-friendly values (strings, bools,
|
||||
// nested maps) and can be marshalled directly.
|
||||
func OpenSearchBuildMapping(t reflect.Type, overrides map[string]FieldOpts) (map[string]any, error) {
|
||||
return buildOpenSearchProperties(t, overrides, "")
|
||||
}
|
||||
|
||||
func buildOpenSearchProperties(t reflect.Type, overrides map[string]FieldOpts, prefix string) (map[string]any, error) {
|
||||
props := map[string]any{}
|
||||
err := walkFields(t, func(fi fieldInfo) error {
|
||||
key := fi.Name
|
||||
if prefix != "" {
|
||||
key = prefix + "." + fi.Name
|
||||
}
|
||||
opts := overrides[key]
|
||||
fieldType := opts.Type
|
||||
if fieldType == "" {
|
||||
fieldType = inferType(fi.GoField.Type)
|
||||
}
|
||||
|
||||
if fieldType == TypeObject {
|
||||
sub := structType(fi.GoField.Type)
|
||||
if sub == nil {
|
||||
return fmt.Errorf("mapping: object type on non-struct field %q", key)
|
||||
}
|
||||
subProps, err := buildOpenSearchProperties(sub, overrides, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
props[fi.Name] = map[string]any{"properties": subProps}
|
||||
return nil
|
||||
}
|
||||
|
||||
if fieldType == TypeGeopoint {
|
||||
// Keep the facet object, add a sibling _geopoint field (see GeopointSuffix).
|
||||
sub := structType(fi.GoField.Type)
|
||||
if sub == nil {
|
||||
return fmt.Errorf("mapping: geopoint type on non-struct field %q", key)
|
||||
}
|
||||
subProps, err := buildOpenSearchProperties(sub, overrides, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
props[fi.Name] = map[string]any{"properties": subProps}
|
||||
props[fi.Name+GeopointSuffix] = map[string]any{"type": "geo_point"}
|
||||
return nil
|
||||
}
|
||||
|
||||
if fieldType == TypeKeyword || fieldType == TypePath {
|
||||
// path_hierarchy is case-preserving here; casing lives in the value.
|
||||
m := map[string]any{"type": "keyword"}
|
||||
if fieldType == TypePath {
|
||||
m = map[string]any{"type": "text", "analyzer": "path_hierarchy"}
|
||||
}
|
||||
props[fi.Name] = m
|
||||
if opts.caseInsensitive() {
|
||||
props[fi.Name+LowercaseSuffix] = m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fm, err := openSearchFieldMapping(fieldType, fi.GoField.Type)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mapping: field %q: %w", key, err)
|
||||
}
|
||||
props[fi.Name] = fm
|
||||
return nil
|
||||
})
|
||||
return props, err
|
||||
}
|
||||
|
||||
// openSearchFieldMapping handles the non-keyword/path types; keyword and path
|
||||
// are emitted (with their cased forms) by buildOpenSearchProperties directly.
|
||||
func openSearchFieldMapping(fieldType string, goType reflect.Type) (map[string]any, error) {
|
||||
switch fieldType {
|
||||
case TypeFulltext:
|
||||
return map[string]any{
|
||||
"type": "text",
|
||||
"term_vector": "with_positions_offsets",
|
||||
"analyzer": "fulltext",
|
||||
}, nil
|
||||
case TypeWildcard:
|
||||
// OpenSearch stores wildcard fields with doc_values=false by
|
||||
// default, so emit it explicitly to keep local and remote
|
||||
// mappings in sync for the Apply comparison.
|
||||
return map[string]any{"type": "wildcard", "doc_values": false}, nil
|
||||
case TypeNumeric:
|
||||
return map[string]any{"type": openSearchNumericType(goType)}, nil
|
||||
case TypeBool:
|
||||
return map[string]any{"type": "boolean"}, nil
|
||||
case TypeDatetime:
|
||||
return map[string]any{"type": "date"}, nil
|
||||
case TypeGeopoint:
|
||||
return map[string]any{"type": "geo_point"}, nil
|
||||
case "":
|
||||
return nil, fmt.Errorf("no type inferred and no override")
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported type %q", fieldType)
|
||||
}
|
||||
|
||||
// openSearchNumericType maps a Go numeric type to an OpenSearch numeric
|
||||
// field type.
|
||||
func openSearchNumericType(t reflect.Type) string {
|
||||
t = deref(t)
|
||||
switch t.Kind() {
|
||||
case reflect.Float32:
|
||||
return "float"
|
||||
case reflect.Float64:
|
||||
return "double"
|
||||
case reflect.Int8, reflect.Uint8, reflect.Int16, reflect.Uint16:
|
||||
return "short"
|
||||
case reflect.Int32, reflect.Uint32:
|
||||
return "integer"
|
||||
}
|
||||
return "long"
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type osDoc struct {
|
||||
ID string `json:"ID"`
|
||||
Size uint64 `json:"Size"`
|
||||
Deleted bool `json:"Deleted"`
|
||||
CreatedAt time.Time `json:"CreatedAt"`
|
||||
Rating float64 `json:"Rating"`
|
||||
Nested *struct {
|
||||
Artist string `json:"artist"`
|
||||
Year int32 `json:"year"`
|
||||
} `json:"nested,omitempty"`
|
||||
}
|
||||
|
||||
var _ = Describe("OpenSearchBuildMapping", func() {
|
||||
DescribeTable("maps numeric Go types to OpenSearch types",
|
||||
func(field, wantType string) {
|
||||
type doc struct {
|
||||
A int8 `json:"a"`
|
||||
B int16 `json:"b"`
|
||||
C int32 `json:"c"`
|
||||
D int64 `json:"d"`
|
||||
E uint8 `json:"e"`
|
||||
F uint64 `json:"f"`
|
||||
G float32 `json:"g"`
|
||||
H float64 `json:"h"`
|
||||
}
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(props[field].(map[string]any)["type"]).To(Equal(wantType), "%s type", field)
|
||||
},
|
||||
Entry("int8 -> short", "a", "short"),
|
||||
Entry("int16 -> short", "b", "short"),
|
||||
Entry("int32 -> integer", "c", "integer"),
|
||||
Entry("int64 -> long", "d", "long"),
|
||||
Entry("uint8 -> short", "e", "short"),
|
||||
Entry("uint64 -> long", "f", "long"),
|
||||
Entry("float32 -> float", "g", "float"),
|
||||
Entry("float64 -> double", "h", "double"),
|
||||
)
|
||||
|
||||
DescribeTable("infers field types",
|
||||
func(field, wantType string) {
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
m, ok := props[field].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "%s: missing or not a map: %#v", field, props[field])
|
||||
Expect(m["type"]).To(Equal(wantType), "%s type", field)
|
||||
},
|
||||
Entry("ID -> keyword", "ID", "keyword"),
|
||||
Entry("Size -> long", "Size", "long"),
|
||||
Entry("Deleted -> boolean", "Deleted", "boolean"),
|
||||
Entry("CreatedAt -> date", "CreatedAt", "date"),
|
||||
Entry("Rating -> double", "Rating", "double"),
|
||||
)
|
||||
|
||||
It("maps nested structs with their sub-properties", func() {
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
nested, ok := props["nested"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "nested: not a map: %#v", props["nested"])
|
||||
sub, ok := nested["properties"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "nested.properties: missing: %#v", nested)
|
||||
artist, ok := sub["artist"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "nested.artist: %#v", sub)
|
||||
Expect(artist["type"]).To(Equal("keyword"), "nested.artist.type")
|
||||
year, ok := sub["year"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "nested.year: %#v", sub)
|
||||
Expect(year["type"]).To(Equal("integer"), "nested.year.type (int32 -> integer expected)")
|
||||
})
|
||||
|
||||
It("applies field overrides", func() {
|
||||
True := true
|
||||
type doc struct {
|
||||
Name string `json:"Name"`
|
||||
Content string `json:"Content"`
|
||||
Path string `json:"Path"`
|
||||
MimeType string `json:"MimeType"`
|
||||
}
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
|
||||
"Name": {CaseInsensitive: &True},
|
||||
"Content": {Type: TypeFulltext},
|
||||
"Path": {Type: TypePath, CaseInsensitive: &True},
|
||||
"MimeType": {Type: TypeWildcard},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Name: case-preserved keyword base + lowercased keyword sibling.
|
||||
Expect(props["Name"]).To(Equal(map[string]any{"type": "keyword"}))
|
||||
Expect(props["Name_lowercase"]).To(Equal(map[string]any{"type": "keyword"}))
|
||||
content := props["Content"].(map[string]any)
|
||||
Expect(content["type"]).To(Equal("text"), "Content: %#v", content)
|
||||
Expect(content["term_vector"]).To(Equal("with_positions_offsets"), "Content: %#v", content)
|
||||
Expect(content["analyzer"]).To(Equal("fulltext"), "Content uses the stemming fulltext analyzer, like bleve")
|
||||
// Path: path_hierarchy base + lowercased sibling, both case-preserving.
|
||||
Expect(props["Path"]).To(Equal(map[string]any{"type": "text", "analyzer": "path_hierarchy"}))
|
||||
Expect(props["Path_lowercase"]).To(Equal(map[string]any{"type": "text", "analyzer": "path_hierarchy"}))
|
||||
mime := props["MimeType"].(map[string]any)
|
||||
Expect(mime["type"]).To(Equal("wildcard"), "MimeType: %#v", mime)
|
||||
})
|
||||
|
||||
It("builds an object plus a geo_point sibling for geopoints", func() {
|
||||
type doc struct {
|
||||
Location *struct {
|
||||
Lon float64 `json:"longitude"`
|
||||
Lat float64 `json:"latitude"`
|
||||
Alt float64 `json:"altitude"`
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
|
||||
"location": {Type: TypeGeopoint},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Object for libregraph-shape data retrieval.
|
||||
loc, ok := props["location"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "location: %#v", props["location"])
|
||||
sub, ok := loc["properties"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "location should have numeric sub-properties, got %#v", loc)
|
||||
for _, k := range []string{"longitude", "latitude", "altitude"} {
|
||||
prop, ok := sub[k].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "location.%s: %#v", k, sub[k])
|
||||
Expect(prop["type"]).To(Equal("double"), "location.%s: %#v", k, sub[k])
|
||||
}
|
||||
// Sibling geo_point for spatial queries.
|
||||
gp, ok := props["location"+GeopointSuffix].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "location%s: %#v", GeopointSuffix, props["location"+GeopointSuffix])
|
||||
Expect(gp["type"]).To(Equal("geo_point"), "location%s.type", GeopointSuffix)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
// Package mapping builds search index mappings for bleve and OpenSearch from
|
||||
// a Go struct via reflection. Field names come from json tags; the caller
|
||||
// provides overrides for fields that need a specific type or analyzer.
|
||||
package mapping
|
||||
|
||||
// Field type constants used in FieldOpts.Type. An empty Type means the type
|
||||
// is inferred from the Go field via reflection.
|
||||
const (
|
||||
TypeKeyword = "keyword"
|
||||
TypeFulltext = "fulltext"
|
||||
TypePath = "path"
|
||||
TypeWildcard = "wildcard"
|
||||
TypeNumeric = "numeric"
|
||||
TypeDatetime = "datetime"
|
||||
TypeBool = "bool"
|
||||
TypeObject = "object"
|
||||
TypeGeopoint = "geopoint"
|
||||
)
|
||||
|
||||
// LowercaseSuffix names the lowercased sibling of a keyword/path field.
|
||||
const LowercaseSuffix = "_lowercase"
|
||||
|
||||
// FieldOpts overrides the default type inference for a struct field. Keys in
|
||||
// the override map are json-tag names (e.g. "Name", "location", "audio.artist"),
|
||||
// not Go field names.
|
||||
type FieldOpts struct {
|
||||
// Type is one of the Type* constants. Empty means "infer from Go type".
|
||||
Type string
|
||||
|
||||
// CaseInsensitive additionally indexes a lowercased <name>_lowercase sibling
|
||||
// for case-insensitive search; the case-preserved base is always indexed.
|
||||
// Nil/false means off. Keyword/path only.
|
||||
CaseInsensitive *bool
|
||||
|
||||
// IncludeInAll controls bleve's _all field inclusion. Nil means "use the
|
||||
// bleve default for this field type". Has no effect on OpenSearch.
|
||||
IncludeInAll *bool
|
||||
}
|
||||
|
||||
func (o FieldOpts) caseInsensitive() bool { return o.CaseInsensitive != nil && *o.CaseInsensitive }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
// SchemaReconciler is the engine-specific half of the startup schema check:
|
||||
// Classify reads stored vs code schema (extras like analyzer drift folded in),
|
||||
// ApplyAdditive applies an additive change. Reconcile drives them so the
|
||||
// verdict-to-action policy lives in one place for every backend.
|
||||
type SchemaReconciler interface {
|
||||
Classify() (Classification, error)
|
||||
// ApplyAdditive applies an additive change and reports whether the schema
|
||||
// was persisted. persisted=true even if a later step fails (e.g. a bleve
|
||||
// reopen), so Reconcile still warns: it is on disk, the next start
|
||||
// classifies equal and stays silent.
|
||||
ApplyAdditive() (persisted bool, err error)
|
||||
}
|
||||
|
||||
// Reconcile runs the shared schema-verdict flow: an equal schema starts
|
||||
// silently, a breaking one refuses with ManualActionRequiredError, an additive
|
||||
// one is applied and warned about. index names the index in the messages.
|
||||
func Reconcile(index string, r SchemaReconciler, logger log.Logger) (Classification, error) {
|
||||
classification, err := r.Classify()
|
||||
if err != nil {
|
||||
return classification, err
|
||||
}
|
||||
|
||||
switch classification.Verdict {
|
||||
case VerdictBreaking:
|
||||
return classification, ManualActionRequiredError(index, classification.Reasons)
|
||||
case VerdictAdditive:
|
||||
persisted, err := r.ApplyAdditive()
|
||||
if persisted {
|
||||
logger.Warn().Strs("fields", classification.NewFields).Str("index", index).Msg("extended the search index mapping with new fields; documents indexed before the upgrade do not contain them and queries on these fields will miss those documents until they are re-indexed; to re-index everything run: opencloud search index --all-spaces --force-rescan")
|
||||
}
|
||||
if err != nil {
|
||||
return classification, err
|
||||
}
|
||||
}
|
||||
|
||||
return classification, nil
|
||||
}
|
||||
|
||||
// LogNewIndexCreated logs that a fresh, empty index was created and how to
|
||||
// backfill it. The create path does not run through Reconcile.
|
||||
func LogNewIndexCreated(logger log.Logger, index string) {
|
||||
logger.Info().Str("index", index).Msg("created a new empty search index; if this OpenCloud instance already held files, they are not in it yet, index them by running: opencloud search index --all-spaces --force-rescan")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
)
|
||||
|
||||
// PrepareForIndex converts v to the flat map[string]any the backend index
|
||||
// clients expect: a json round-trip (conversions.To) plus type-specific
|
||||
// adaptations (currently geopoint siblings). Pass the same overrides as the
|
||||
// *BuildMapping calls so the document and the mapping stay in sync.
|
||||
func PrepareForIndex(v any, overrides map[string]FieldOpts) (map[string]any, error) {
|
||||
out, err := conversions.To[map[string]any](v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mapping: prepare %T: %w", v, err)
|
||||
}
|
||||
if out == nil {
|
||||
return out, nil
|
||||
}
|
||||
addGeopointSiblings(out, overrides)
|
||||
addLowercaseSiblings(out, overrides)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PrepareForIndex serialization", func() {
|
||||
It("errors for a non-marshallable value", func() {
|
||||
// a func field can't be json-marshalled -> conversions.To errors
|
||||
type bad struct {
|
||||
F func() `json:"f"`
|
||||
}
|
||||
_, err := PrepareForIndex(bad{}, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns nil map for a typed nil pointer", func() {
|
||||
// a typed nil pointer marshals to null -> nil map, no error, no panic
|
||||
out, err := PrepareForIndex((*struct{})(nil), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(out).To(BeNil())
|
||||
})
|
||||
|
||||
It("flattens embedded structs", func() {
|
||||
type inner struct {
|
||||
Name string `json:"Name"`
|
||||
Size uint64 `json:"Size"`
|
||||
}
|
||||
type outer struct {
|
||||
inner
|
||||
ID string `json:"ID"`
|
||||
}
|
||||
m, err := PrepareForIndex(outer{inner: inner{Name: "a", Size: 7}, ID: "x"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
want := map[string]any{"Name": "a", "Size": float64(7), "ID": "x"}
|
||||
Expect(m).To(Equal(want))
|
||||
})
|
||||
|
||||
It("omits nil fields tagged omitempty", func() {
|
||||
type facet struct {
|
||||
Artist string `json:"artist"`
|
||||
}
|
||||
type doc struct {
|
||||
Name string `json:"Name"`
|
||||
Audio *facet `json:"audio,omitempty"`
|
||||
}
|
||||
m, err := PrepareForIndex(doc{Name: "n"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).ToNot(HaveKey("audio"))
|
||||
Expect(m["Name"]).To(Equal("n"))
|
||||
})
|
||||
|
||||
It("includes nested facets when set", func() {
|
||||
type facet struct {
|
||||
Artist string `json:"artist"`
|
||||
}
|
||||
type doc struct {
|
||||
Audio *facet `json:"audio,omitempty"`
|
||||
}
|
||||
m, err := PrepareForIndex(doc{Audio: &facet{Artist: "A"}}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
nested, ok := m["audio"].(map[string]any)
|
||||
Expect(ok).To(BeTrue(), "audio should be a nested map: %#v", m["audio"])
|
||||
Expect(nested["artist"]).To(Equal("A"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate returns an error if any override key does not match a known field
|
||||
// name in t. Top-level fields are identified by their json-tag name; nested
|
||||
// named struct fields are reachable as "parent.child". Embedded (anonymous)
|
||||
// structs are flattened, so their fields sit at the parent level (as with
|
||||
// encoding/json).
|
||||
func Validate(t reflect.Type, overrides map[string]FieldOpts) error {
|
||||
if len(overrides) == 0 {
|
||||
return nil
|
||||
}
|
||||
fields := collectFields(t, "")
|
||||
var unknown, miscased []string
|
||||
for k, opts := range overrides {
|
||||
goType, ok := fields[k]
|
||||
if !ok {
|
||||
unknown = append(unknown, k)
|
||||
continue
|
||||
}
|
||||
// CaseInsensitive routes queries to a <field>_lowercase sibling, which is
|
||||
// only generated for keyword/path fields; on any other type the query
|
||||
// would target a non-existent field and silently match nothing. Use the
|
||||
// effective type (override, else the inferred Go type), since an override
|
||||
// with no explicit Type still infers keyword/numeric/... from the field.
|
||||
if opts.caseInsensitive() && !effectivelyCased(opts, goType) {
|
||||
miscased = append(miscased, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return fmt.Errorf("mapping: unknown override keys: %s", strings.Join(unknown, ", "))
|
||||
}
|
||||
if len(miscased) > 0 {
|
||||
sort.Strings(miscased)
|
||||
return fmt.Errorf("mapping: CaseInsensitive is only valid on keyword/path fields: %s", strings.Join(miscased, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectivelyCased reports whether a field is keyword/path (the only types that
|
||||
// get a _lowercase sibling), from the override type or the inferred Go type.
|
||||
func effectivelyCased(opts FieldOpts, goType reflect.Type) bool {
|
||||
eff := opts.Type
|
||||
if eff == "" && goType != nil {
|
||||
eff = inferType(goType)
|
||||
}
|
||||
return eff == TypeKeyword || eff == TypePath
|
||||
}
|
||||
|
||||
// collectFields maps every known field name (nested as "parent.child") to its Go
|
||||
// type. Embedded structs are flattened, matching encoding/json.
|
||||
func collectFields(t reflect.Type, prefix string) map[string]reflect.Type {
|
||||
out := map[string]reflect.Type{}
|
||||
_ = walkFields(t, func(fi fieldInfo) error {
|
||||
key := fi.Name
|
||||
if prefix != "" {
|
||||
key = prefix + "." + fi.Name
|
||||
}
|
||||
out[key] = fi.GoField.Type
|
||||
if sub := structType(fi.GoField.Type); sub != nil {
|
||||
for k, v := range collectFields(sub, key) {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type inner struct {
|
||||
Artist string `json:"artist"`
|
||||
}
|
||||
|
||||
type sample struct {
|
||||
Name string `json:"Name"`
|
||||
Audio *inner `json:"audio,omitempty"`
|
||||
Location *struct { //nolint:unused
|
||||
Lon float64 `json:"longitude"`
|
||||
Lat float64 `json:"latitude"`
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
var _ = Describe("Validate", func() {
|
||||
It("accepts known override keys", func() {
|
||||
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
|
||||
"Name": {},
|
||||
"audio": {Type: TypeObject},
|
||||
"audio.artist": {},
|
||||
"location": {Type: TypeGeopoint},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects unknown override keys", func() {
|
||||
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
|
||||
"nope": {},
|
||||
"audio.zzz": {},
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("nope"))
|
||||
Expect(err.Error()).To(ContainSubstring("audio.zzz"))
|
||||
})
|
||||
|
||||
It("accepts empty overrides", func() {
|
||||
Expect(Validate(reflect.TypeFor[sample](), nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("rejects CaseInsensitive on a non-keyword/path field", func() {
|
||||
True := true
|
||||
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
|
||||
"Name": {Type: TypeFulltext, CaseInsensitive: &True},
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("Name"))
|
||||
Expect(err.Error()).To(ContainSubstring("CaseInsensitive"))
|
||||
})
|
||||
|
||||
It("rejects CaseInsensitive on an inferred non-keyword field (empty Type)", func() {
|
||||
True := true
|
||||
type doc struct {
|
||||
Size uint64 `json:"Size"`
|
||||
}
|
||||
err := Validate(reflect.TypeFor[doc](), map[string]FieldOpts{
|
||||
"Size": {CaseInsensitive: &True}, // no explicit Type -> inferred numeric
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("Size"))
|
||||
})
|
||||
|
||||
It("accepts CaseInsensitive on an inferred keyword field (empty Type)", func() {
|
||||
True := true
|
||||
type doc struct {
|
||||
Name string `json:"Name"`
|
||||
Tags []string `json:"Tags"`
|
||||
}
|
||||
Expect(Validate(reflect.TypeFor[doc](), map[string]FieldOpts{
|
||||
"Name": {CaseInsensitive: &True},
|
||||
"Tags": {CaseInsensitive: &True},
|
||||
})).To(Succeed())
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ package opensearch
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storageProvider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
|
||||
@@ -31,8 +31,8 @@ type Backend struct {
|
||||
client *opensearchgoAPI.Client
|
||||
}
|
||||
|
||||
func NewBackend(index string, client *opensearchgoAPI.Client) (*Backend, error) {
|
||||
pingResp, err := client.Ping(context.TODO(), &opensearchgoAPI.PingReq{})
|
||||
func NewBackend(ctx context.Context, index string, client *opensearchgoAPI.Client, logger log.Logger) (*Backend, error) {
|
||||
pingResp, err := client.Ping(ctx, &opensearchgoAPI.PingReq{})
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w, failed to ping opensearch: %w", ErrUnhealthyCluster, err)
|
||||
@@ -41,13 +41,13 @@ func NewBackend(index string, client *opensearchgoAPI.Client) (*Backend, error)
|
||||
}
|
||||
|
||||
// apply the index template
|
||||
if err := IndexManagerLatest.Apply(context.TODO(), index, client); err != nil {
|
||||
if err := IndexManagerLatest.Apply(ctx, index, client, logger); 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{
|
||||
resp, err := client.Cluster.Health(ctx, &opensearchgoAPI.ClusterHealthReq{
|
||||
Indices: []string{index},
|
||||
Params: opensearchgoAPI.ClusterHealthParams{
|
||||
Local: opensearchgoAPI.ToPointer(true),
|
||||
@@ -90,6 +90,15 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
|
||||
),
|
||||
),
|
||||
)
|
||||
// Scope below the space root: restrict at query level so totals and
|
||||
// paging respect the path too. Path uses the case-preserving
|
||||
// path_hierarchy analyzer, so the folder path is an indexed token of
|
||||
// the folder itself and every descendant.
|
||||
if requestedPath := utils.MakeRelativePath(sir.Ref.Path); requestedPath != "." {
|
||||
boolQuery.Filter(
|
||||
osu.NewTermQuery[string]("Path").Value(requestedPath),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
searchParams := opensearchgoAPI.SearchParams{
|
||||
@@ -142,17 +151,6 @@ func (b *Backend) Search(ctx context.Context, sir *searchService.SearchIndexRequ
|
||||
return nil, fmt.Errorf("failed to convert hit to match: %w", err)
|
||||
}
|
||||
|
||||
if sir.Ref != nil {
|
||||
hitPath := strings.TrimSuffix(match.GetEntity().GetRef().GetPath(), "/")
|
||||
requestedPath := utils.MakeRelativePath(sir.Ref.Path)
|
||||
isRoot := hitPath == requestedPath
|
||||
|
||||
if !isRoot && requestedPath != "." && !strings.HasPrefix(hitPath, requestedPath+"/") {
|
||||
totalMatches--
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
matches = append(matches, match)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
|
||||
opensearchtest "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
|
||||
@@ -61,7 +63,7 @@ func newBackend(indexName string, resources ...search.Resource) (*opensearch.Bac
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
|
||||
backend, err := opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err := opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
for _, r := range resources {
|
||||
@@ -82,7 +84,7 @@ var _ = Describe("Backend", func() {
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred(), "failed to create OpenSearch client")
|
||||
|
||||
backend, err := opensearch.NewBackend("test-engine-new-engine", client)
|
||||
backend, err := opensearch.NewBackend(context.Background(), "test-engine-new-engine", client, log.NopLogger())
|
||||
Expect(backend).To(BeNil())
|
||||
Expect(err).To(MatchError(opensearch.ErrUnhealthyCluster))
|
||||
})
|
||||
@@ -104,11 +106,14 @@ var _ = Describe("Backend", func() {
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
document = opensearchtest.Testdata.Resources.File
|
||||
tc.Require.DocumentCreate(indexName, document.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), document)))
|
||||
// Upsert (not DocumentCreate) so PrepareForIndex writes the _lowercase
|
||||
// siblings the case-insensitive Name and path scope searches rely on.
|
||||
Expect(backend.Upsert(document.ID, document)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
})
|
||||
|
||||
@@ -127,7 +132,8 @@ var _ = Describe("Backend", func() {
|
||||
deletedDocument.ID = "1$2!4"
|
||||
deletedDocument.Deleted = true
|
||||
|
||||
tc.Require.DocumentCreate(indexName, deletedDocument.ID, strings.NewReader(opensearchtest.JSONMustMarshal(GinkgoTB(), deletedDocument)))
|
||||
Expect(backend.Upsert(deletedDocument.ID, deletedDocument)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 2)
|
||||
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
|
||||
@@ -138,6 +144,202 @@ var _ = Describe("Backend", func() {
|
||||
Expect(resp.TotalMatches).To(Equal(int32(1)))
|
||||
Expect(fmt.Sprintf("%s$%s!%s", resp.Matches[0].Entity.Id.StorageId, resp.Matches[0].Entity.Id.SpaceId, resp.Matches[0].Entity.Id.OpaqueId)).To(Equal(document.ID))
|
||||
})
|
||||
|
||||
It("restricts hits and totals to the path scope", func() {
|
||||
outside := opensearchtest.Testdata.Resources.File
|
||||
outside.ID = "1$1!5"
|
||||
outside.Path = "./other folder/else.jpg"
|
||||
Expect(backend.Upsert(outside.ID, outside)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
|
||||
scoped := &searchMessage.Reference{
|
||||
ResourceId: &searchMessage.ResourceID{StorageId: "1", SpaceId: "1", OpaqueId: "1"},
|
||||
Path: "./parent d!r",
|
||||
}
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
|
||||
Query: fmt.Sprintf(`"%s"`, document.Name),
|
||||
Ref: scoped,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Matches).To(HaveLen(1))
|
||||
Expect(resp.TotalMatches).To(Equal(int32(1)))
|
||||
Expect(resp.Matches[0].Entity.Ref.Path).To(Equal("./parent d!r/child.jpg"))
|
||||
|
||||
// the scope is a reference and matches case-sensitively
|
||||
wrongCase := &searchMessage.Reference{
|
||||
ResourceId: &searchMessage.ResourceID{StorageId: "1", SpaceId: "1", OpaqueId: "1"},
|
||||
Path: "./PARENT D!R",
|
||||
}
|
||||
respWrongCase, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{
|
||||
Query: fmt.Sprintf(`"%s"`, document.Name),
|
||||
Ref: wrongCase,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(respWrongCase.Matches).To(HaveLen(0))
|
||||
Expect(respWrongCase.TotalMatches).To(Equal(int32(0)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FullTextSearch", func() {
|
||||
const indexName = "opencloud-test-engine-fulltext"
|
||||
|
||||
var (
|
||||
tc *opensearchtest.TestClient
|
||||
backend *opensearch.Backend
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
document.Content = "Running Foxes"
|
||||
Expect(backend.Upsert(document.ID, document)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
})
|
||||
|
||||
It("searches content case-insensitively and stemmed, like bleve", func() {
|
||||
// case-folded and porter-stemmed by the fulltext analyzer; the match
|
||||
// query analyzes the query value the same way. "content:run*" is an
|
||||
// unanalyzed wildcard over the stemmed term "run", so it must still
|
||||
// route to a wildcard query (not degrade to a phrase match).
|
||||
for _, q := range []string{"content:running", "content:RUNNING", "content:run", "content:run*"} {
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: q})
|
||||
Expect(err).ToNot(HaveOccurred(), q)
|
||||
Expect(resp.Matches).To(HaveLen(1), q)
|
||||
}
|
||||
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: "content:cat"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Matches).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CaseInsensitiveSearch", func() {
|
||||
const indexName = "opencloud-test-engine-ci"
|
||||
|
||||
var (
|
||||
tc *opensearchtest.TestClient
|
||||
backend *opensearch.Backend
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
folder := opensearchtest.Testdata.Resources.Folder
|
||||
folder.ID = "1$2!cifolder"
|
||||
folder.Path = "./My Dir"
|
||||
folder.Tags = []string{"Work", "Urgent"}
|
||||
Expect(backend.Upsert(folder.ID, folder)).To(Succeed())
|
||||
|
||||
child := opensearchtest.Testdata.Resources.File
|
||||
child.ID = "1$2!cichild"
|
||||
child.ParentID = folder.ID
|
||||
child.Path = "./My Dir/report.pdf"
|
||||
child.Tags = nil
|
||||
Expect(backend.Upsert(child.ID, child)).To(Succeed())
|
||||
|
||||
// a doc outside the folder, so the path assertions below discriminate:
|
||||
// a phrase-matched path query would analyze into the "." prefix and
|
||||
// match this one too
|
||||
outside := opensearchtest.Testdata.Resources.File
|
||||
outside.ID = "1$2!cioutside"
|
||||
outside.Path = "./other.pdf"
|
||||
outside.Tags = nil
|
||||
Expect(backend.Upsert(outside.ID, outside)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
})
|
||||
|
||||
It("matches tags case-insensitively (array sibling)", func() {
|
||||
for _, q := range []string{"tag:work", "tag:WORK", "Tags:Urgent"} {
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: q})
|
||||
Expect(err).ToNot(HaveOccurred(), q)
|
||||
Expect(resp.Matches).To(HaveLen(1), q)
|
||||
}
|
||||
})
|
||||
|
||||
It("matches a spaced path on the folder and its descendants case-sensitively", func() {
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./My Dir"`})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Matches).To(HaveLen(2)) // folder itself + the descendant, not the outside doc
|
||||
|
||||
// paths act as references, a wrong-cased path must not match
|
||||
respWrongCase, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./MY DIR"`})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(respWrongCase.Matches).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("matches a spaced descendant path only itself", func() {
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./My Dir/report.pdf"`})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Matches).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MediaTypeSearch", func() {
|
||||
const indexName = "opencloud-test-engine-mediatype"
|
||||
|
||||
var (
|
||||
tc *opensearchtest.TestClient
|
||||
backend *opensearch.Backend
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
tc = opensearchtest.NewDefaultTestClient(GinkgoTB(), defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 0)
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
svg := opensearchtest.Testdata.Resources.File
|
||||
svg.ID = "1$2!svg"
|
||||
svg.MimeType = "image/svg+xml"
|
||||
Expect(backend.Upsert(svg.ID, svg)).To(Succeed())
|
||||
|
||||
png := opensearchtest.Testdata.Resources.File
|
||||
png.ID = "1$2!png"
|
||||
png.MimeType = "image/png"
|
||||
Expect(backend.Upsert(png.ID, png)).To(Succeed())
|
||||
|
||||
folder := opensearchtest.Testdata.Resources.Folder
|
||||
folder.ID = "1$2!dir"
|
||||
folder.MimeType = "httpd/unix-directory"
|
||||
Expect(backend.Upsert(folder.ID, folder)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
})
|
||||
|
||||
DescribeTable("resolves the media type query",
|
||||
func(query string, want int) {
|
||||
resp, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: query})
|
||||
Expect(err).ToNot(HaveOccurred(), query)
|
||||
Expect(resp.Matches).To(HaveLen(want), query)
|
||||
},
|
||||
Entry("image/* wildcard matches both files", "mediatype:image", 2),
|
||||
Entry("categories are case-insensitive", "mediatype:IMAGE", 2),
|
||||
Entry("literal MIME (+ and /) via mediatype", "mediatype:image/svg+xml", 1),
|
||||
Entry("same literal via the raw field name", "MimeType:image/svg+xml", 1),
|
||||
Entry("literal png MIME", "mediatype:image/png", 1),
|
||||
Entry("no pdf documents", "mediatype:pdf", 0),
|
||||
Entry("folder category matches the directory only", "mediatype:folder", 1),
|
||||
Entry("file category matches both files, not the directory", "mediatype:file", 2),
|
||||
Entry("file category combined with a term", "mediatype:file AND MimeType:image/png", 1),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Upsert", func() {
|
||||
@@ -155,7 +357,7 @@ var _ = Describe("Backend", func() {
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
@@ -165,6 +367,16 @@ var _ = Describe("Backend", func() {
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
})
|
||||
|
||||
It("upserts a document without an mtime", func() {
|
||||
// content.Extract leaves Mtime nil when the resource info carries none
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
document.ID = "1$1!4"
|
||||
document.Mtime = nil
|
||||
Expect(backend.Upsert(document.ID, document)).To(Succeed())
|
||||
|
||||
tc.Require.IndicesCount([]string{indexName}, nil, 1)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Move", func() {
|
||||
@@ -182,7 +394,7 @@ var _ = Describe("Backend", func() {
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
@@ -210,6 +422,34 @@ var _ = Describe("Backend", func() {
|
||||
Expect(resources).To(HaveLen(1))
|
||||
Expect(resources[0].Path).To(Equal(document.Path))
|
||||
})
|
||||
|
||||
It("keeps case-sensitive path search working after a move", func() {
|
||||
// Spaced paths so the queries only stay exact as term queries; a phrase
|
||||
// match would analyze into the "." prefix and match regardless.
|
||||
document := opensearchtest.Testdata.Resources.File
|
||||
document.ID = "1$2!cimove"
|
||||
document.Path = "./Foo Dir/Bar"
|
||||
Expect(backend.Upsert(document.ID, document)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
|
||||
document.Path = "./Moved Dir/Bar"
|
||||
Expect(backend.Move(document.ID, document.ParentID, document.Path)).To(Succeed())
|
||||
tc.Require.IndicesRefresh([]string{indexName}, nil)
|
||||
|
||||
// Path is case-sensitive by design: the exact new path matches, a
|
||||
// wrong-cased query does not, and the old path no longer matches.
|
||||
respNew, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./Moved Dir/Bar"`})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(respNew.Matches).To(HaveLen(1))
|
||||
|
||||
respWrongCase, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./MOVED DIR/BAR"`})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(respWrongCase.Matches).To(HaveLen(0))
|
||||
|
||||
respOld, err := backend.Search(context.Background(), &searchService.SearchIndexRequest{Query: `path:"./Foo Dir/Bar"`})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(respOld.Matches).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
@@ -227,7 +467,7 @@ var _ = Describe("Backend", func() {
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
@@ -268,7 +508,7 @@ var _ = Describe("Backend", func() {
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
@@ -310,7 +550,7 @@ var _ = Describe("Backend", func() {
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
@@ -370,7 +610,7 @@ var _ = Describe("Backend", func() {
|
||||
deleteIndexOnCleanup(tc, indexName)
|
||||
|
||||
var err error
|
||||
backend, err = opensearch.NewBackend(indexName, tc.Client())
|
||||
backend, err = opensearch.NewBackend(context.Background(), indexName, tc.Client(), log.NopLogger())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/conversions"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
@@ -43,7 +44,7 @@ func NewBatch(client *opensearchgoAPI.Client, index string, size int) (*Batch, e
|
||||
|
||||
func (b *Batch) Upsert(id string, r search.Resource) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
body, err := conversions.To[map[string]any](r)
|
||||
body, err := mapping.PrepareForIndex(r, r.SearchFieldOverrides())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal resource: %w", err)
|
||||
}
|
||||
@@ -67,18 +68,36 @@ func (b *Batch) Move(id, parentID, location string) error {
|
||||
return b.withSizeLimit(func() error {
|
||||
op := func() error {
|
||||
return updateSelfAndDescendants(context.Background(), b.client, b.index, id, func(rootResource search.Resource) *osu.BodyParamScript {
|
||||
newPath := utils.MakeRelativePath(location)
|
||||
newName := path.Base(newPath)
|
||||
return &osu.BodyParamScript{
|
||||
Source: `
|
||||
if (ctx._source.ID == params.id ) { ctx._source.Name = params.newName; ctx._source.ParentID = params.parentID; }
|
||||
ctx._source.Path = ctx._source.Path.replace(params.oldPath, params.newPath)
|
||||
`,
|
||||
// Keep Name and its lowercased search sibling in sync; Path has
|
||||
// no sibling (case-sensitive by design). Only the leading
|
||||
// oldPath is replaced (startsWith + substring, not
|
||||
// String.replace, which would also rewrite a repeated segment
|
||||
// deeper in a descendant's path, e.g. /Music/Music.m3u). The
|
||||
// lowercased new name comes from Go's strings.ToLower via
|
||||
// params, so the sibling stays byte-identical to what
|
||||
// PrepareForIndex writes on upsert (painless toLowerCase would
|
||||
// lowercase differently than Go).
|
||||
Source: fmt.Sprintf(`
|
||||
if (ctx._source.ID == params.id) {
|
||||
ctx._source.Name = params.newName;
|
||||
ctx._source.ParentID = params.parentID;
|
||||
if (ctx._source.Name%[1]s != null) { ctx._source.Name%[1]s = params.newNameLower; }
|
||||
}
|
||||
if (ctx._source.Path != null && ctx._source.Path.startsWith(params.oldPath)) {
|
||||
ctx._source.Path = params.newPath + ctx._source.Path.substring(params.oldPath.length());
|
||||
}
|
||||
`, mapping.LowercaseSuffix),
|
||||
Lang: "painless",
|
||||
Params: map[string]any{
|
||||
"id": id,
|
||||
"parentID": parentID,
|
||||
"oldPath": rootResource.Path,
|
||||
"newPath": utils.MakeRelativePath(location),
|
||||
"newName": path.Base(utils.MakeRelativePath(location)),
|
||||
"id": id,
|
||||
"parentID": parentID,
|
||||
"oldPath": rootResource.Path,
|
||||
"newPath": newPath,
|
||||
"newName": newName,
|
||||
"newNameLower": strings.ToLower(newName),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package opensearch
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/config"
|
||||
)
|
||||
|
||||
// NewClient builds an OpenSearch API client from the engine client config.
|
||||
func NewClient(cfg config.EngineOpenSearchClient) (*opensearchgoAPI.Client, error) {
|
||||
clientConfig := opensearchgo.Config{
|
||||
Addresses: cfg.Addresses,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
Header: cfg.Header,
|
||||
RetryOnStatus: cfg.RetryOnStatus,
|
||||
DisableRetry: cfg.DisableRetry,
|
||||
EnableRetryOnTimeout: cfg.EnableRetryOnTimeout,
|
||||
MaxRetries: cfg.MaxRetries,
|
||||
CompressRequestBody: cfg.CompressRequestBody,
|
||||
DiscoverNodesOnStart: cfg.DiscoverNodesOnStart,
|
||||
DiscoverNodesInterval: cfg.DiscoverNodesInterval,
|
||||
EnableMetrics: cfg.EnableMetrics,
|
||||
EnableDebugLogger: cfg.EnableDebugLogger,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: cfg.Insecure,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.CACert != "" {
|
||||
certBytes, err := os.ReadFile(cfg.CACert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read CA cert: %w", err)
|
||||
}
|
||||
clientConfig.CACert = certBytes
|
||||
}
|
||||
|
||||
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{Client: clientConfig})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OpenSearch client: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
@@ -3,29 +3,44 @@ package opensearch
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"maps"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/go-jose/go-jose/v3/json"
|
||||
opensearchgo "github.com/opensearch-project/opensearch-go/v4"
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
searchmapping "github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrManualActionRequired = errors.New("manual action required")
|
||||
IndexManagerLatest = IndexIndexManagerResourceV2
|
||||
IndexIndexManagerResourceV1 IndexManager = "resource_v1.json"
|
||||
IndexIndexManagerResourceV2 IndexManager = "resource_v2.json"
|
||||
// ErrManualActionRequired is the shared sentinel, see the mapping package.
|
||||
ErrManualActionRequired = searchmapping.ErrManualActionRequired
|
||||
|
||||
// IndexManagerLatest identifies the current resource mapping; its version is
|
||||
// derived from search.SchemaVersion so it never drifts from the index name.
|
||||
IndexManagerLatest = IndexManager(fmt.Sprintf("resource_v%d", search.SchemaVersion))
|
||||
)
|
||||
|
||||
//go:embed internal/indexes/*.json
|
||||
var indexes embed.FS
|
||||
// VersionedIndexName suffixes the base index name with the schema version, e.g.
|
||||
// "opencloud-resource" -> "opencloud-resource-v3".
|
||||
func VersionedIndexName(base string) string {
|
||||
return fmt.Sprintf("%s-v%d", base, search.SchemaVersion)
|
||||
}
|
||||
|
||||
type IndexManager string
|
||||
|
||||
// indexGenerators dispatches each IndexManager variant to its builder.
|
||||
var indexGenerators = map[IndexManager]func() ([]byte, error){
|
||||
IndexManagerLatest: buildResourceMapping,
|
||||
}
|
||||
|
||||
func (m IndexManager) String() string {
|
||||
b, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
@@ -36,108 +51,208 @@ func (m IndexManager) String() string {
|
||||
}
|
||||
|
||||
func (m IndexManager) MarshalJSON() ([]byte, error) {
|
||||
filePath := string(m)
|
||||
body, err := indexes.ReadFile(path.Join("./internal/indexes", filePath))
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("failed to read index file %s: %w", filePath, err)
|
||||
case len(body) <= 0:
|
||||
return nil, fmt.Errorf("index file %s is empty", filePath)
|
||||
gen, ok := indexGenerators[m]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown index manager %q", string(m))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
return gen()
|
||||
}
|
||||
|
||||
func (m IndexManager) Apply(ctx context.Context, name string, client *opensearchgoAPI.Client) error {
|
||||
// buildResourceMapping renders the OpenSearch index template for a
|
||||
// search.Resource from the shared SearchFieldOverrides. OpenSearch-specific
|
||||
// tweaks (wildcard MimeType, path_hierarchy Path) are applied on top.
|
||||
func buildResourceMapping() ([]byte, error) {
|
||||
resourceType := reflect.TypeFor[search.Resource]()
|
||||
overrides := maps.Clone(search.Resource{}.SearchFieldOverrides())
|
||||
overrides["MimeType"] = searchmapping.FieldOpts{Type: searchmapping.TypeWildcard}
|
||||
if err := searchmapping.Validate(resourceType, overrides); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
props, err := searchmapping.OpenSearchBuildMapping(resourceType, overrides)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
index := map[string]any{
|
||||
"settings": map[string]any{
|
||||
"number_of_shards": "1",
|
||||
"number_of_replicas": "1",
|
||||
"analysis": map[string]any{
|
||||
// path_hierarchy is case-preserving; casing lives in the value.
|
||||
// fulltext mirrors the bleve fulltext analyzer (lowercase + porter
|
||||
// stemming) so full-text search behaves the same on both backends.
|
||||
"analyzer": map[string]any{
|
||||
"path_hierarchy": map[string]any{
|
||||
"type": "custom",
|
||||
"tokenizer": "path_hierarchy",
|
||||
},
|
||||
"fulltext": map[string]any{
|
||||
"type": "custom",
|
||||
"tokenizer": "standard",
|
||||
"filter": []string{"lowercase", "porter_stem"},
|
||||
},
|
||||
},
|
||||
"tokenizer": map[string]any{
|
||||
"path_hierarchy": map[string]any{"type": "path_hierarchy"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"mappings": map[string]any{
|
||||
"properties": props,
|
||||
},
|
||||
}
|
||||
return json.Marshal(index)
|
||||
}
|
||||
|
||||
// Apply ensures the index exists and matches the code schema: created if
|
||||
// missing, otherwise reconciled via searchmapping.Reconcile (see osReconciler).
|
||||
func (m IndexManager) Apply(ctx context.Context, name string, client *opensearchgoAPI.Client, logger log.Logger) error {
|
||||
localIndexB, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal index %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Exists first: a pre-provisioned index must not require create privileges
|
||||
indicesExistsResp, err := client.Indices.Exists(ctx, opensearchgoAPI.IndicesExistsReq{
|
||||
Indices: []string{name},
|
||||
})
|
||||
switch {
|
||||
case indicesExistsResp != nil && indicesExistsResp.StatusCode == 404:
|
||||
break
|
||||
createResp, createErr := client.Indices.Create(ctx, opensearchgoAPI.IndicesCreateReq{
|
||||
Index: name,
|
||||
Body: bytes.NewReader(localIndexB),
|
||||
})
|
||||
var structErr *opensearchgo.StructError
|
||||
switch {
|
||||
case createErr == nil && createResp.Acknowledged:
|
||||
searchmapping.LogNewIndexCreated(logger, name)
|
||||
return nil
|
||||
case createErr == nil:
|
||||
return fmt.Errorf("failed to create index %s: not acknowledged", name)
|
||||
case !errors.As(createErr, &structErr) || structErr.Err.Type != "resource_already_exists_exception":
|
||||
// transport errors, disk-full etc. stay plain fatal, the restart policy retries
|
||||
return fmt.Errorf("failed to create index %s: %w", name, createErr)
|
||||
}
|
||||
// lost the creation race to another instance, compare against its index
|
||||
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),
|
||||
// the index exists: reconcile its schema through the shared verdict flow
|
||||
resp, err := client.Indices.Get(ctx, opensearchgoAPI.IndicesGetReq{
|
||||
Indices: []string{name},
|
||||
})
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
r := &osReconciler{
|
||||
ctx: ctx,
|
||||
name: name,
|
||||
client: client,
|
||||
local: gjson.ParseBytes(localIndexB),
|
||||
remote: gjson.ParseBytes(remoteIndexB),
|
||||
}
|
||||
_, err = searchmapping.Reconcile(name, r, logger)
|
||||
return err
|
||||
}
|
||||
|
||||
// osReconciler adapts an existing OpenSearch index to searchmapping.SchemaReconciler.
|
||||
type osReconciler struct {
|
||||
ctx context.Context
|
||||
name string
|
||||
client *opensearchgoAPI.Client
|
||||
local gjson.Result
|
||||
remote gjson.Result
|
||||
}
|
||||
|
||||
func (r *osReconciler) Classify() (searchmapping.Classification, error) {
|
||||
// Only the analysis settings affect indexing correctness; shard/replica
|
||||
// counts and other operational knobs are the operator's to tune (and a
|
||||
// pre-provisioned index's to own), so they are not compared.
|
||||
var reasons []string
|
||||
lv := r.local.Get("settings.analysis").Raw
|
||||
rv := r.remote.Get("settings.index.analysis").Raw
|
||||
if !jsonEqual(lv, rv) {
|
||||
reasons = append(reasons, fmt.Sprintf("settings.analysis changed: index %s, code %s", rawOrUnset(rv), rawOrUnset(lv)))
|
||||
}
|
||||
|
||||
classification := searchmapping.Classify(
|
||||
propertiesMap(r.remote.Get("mappings.properties").Raw),
|
||||
propertiesMap(r.local.Get("mappings.properties").Raw),
|
||||
nil,
|
||||
)
|
||||
reasons = append(reasons, classification.Reasons...)
|
||||
if len(reasons) > 0 {
|
||||
classification.Verdict = searchmapping.VerdictBreaking
|
||||
classification.Reasons = reasons
|
||||
}
|
||||
return classification, nil
|
||||
}
|
||||
|
||||
// ApplyAdditive puts the full code properties (only additions, per the
|
||||
// classifier). The PUT is atomic, so persisted is true only on success.
|
||||
func (r *osReconciler) ApplyAdditive() (bool, error) {
|
||||
putResp, err := r.client.Indices.Mapping.Put(r.ctx, opensearchgoAPI.MappingPutReq{
|
||||
Indices: []string{r.name},
|
||||
Body: strings.NewReader(r.local.Get("mappings").Raw),
|
||||
})
|
||||
var putErr *opensearchgo.StructError
|
||||
switch {
|
||||
case err != nil && errors.As(err, &putErr) && putErr.Err.Type == "illegal_argument_exception" &&
|
||||
(strings.Contains(putErr.Err.Reason, "cannot be changed") || strings.Contains(putErr.Err.Reason, "Cannot update parameter")):
|
||||
// backstop, should be unreachable after the classification above
|
||||
return false, searchmapping.ManualActionRequiredError(r.name, []string{putErr.Err.Reason})
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("failed to update mapping of index %s: %w", r.name, err)
|
||||
case !putResp.Acknowledged:
|
||||
return false, fmt.Errorf("failed to update mapping of index %s: not acknowledged", r.name)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// jsonEqual reports whether two raw JSON values are deeply equal. gjson yields
|
||||
// an empty string for a path that does not exist; two such unset values are
|
||||
// equal, an unset value never equals a present one, and a value that fails to
|
||||
// parse counts as unequal.
|
||||
func jsonEqual(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return a == b
|
||||
}
|
||||
var av, bv any
|
||||
if err := json.Unmarshal([]byte(a), &av); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &bv); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(av, bv)
|
||||
}
|
||||
|
||||
// propertiesMap parses a raw mappings.properties object into a map. Missing,
|
||||
// empty, null or malformed input yields an empty (non-nil) map, which
|
||||
// classifies as purely additive.
|
||||
func propertiesMap(raw string) map[string]any {
|
||||
props := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(raw), &props); err != nil || props == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
func rawOrUnset(raw string) string {
|
||||
if raw == "" {
|
||||
return "(unset)"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
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"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// TestVersionedIndexName guards that the index name and the generator identity
|
||||
// carry the same schema version.
|
||||
func TestVersionedIndexName(t *testing.T) {
|
||||
require.Equal(t,
|
||||
fmt.Sprintf("opencloud-resource-v%d", search.SchemaVersion),
|
||||
opensearch.VersionedIndexName("opencloud-resource"),
|
||||
)
|
||||
require.Equal(t,
|
||||
fmt.Sprintf("resource_v%d", search.SchemaVersion),
|
||||
string(opensearch.IndexManagerLatest),
|
||||
)
|
||||
}
|
||||
|
||||
func TestIndexManager(t *testing.T) {
|
||||
t.Run("index plausibility", func(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[opensearch.IndexManager, struct{}]{
|
||||
@@ -31,7 +50,7 @@ func TestIndexManager(t *testing.T) {
|
||||
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()))
|
||||
require.NoError(t, test.Got.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -44,10 +63,38 @@ func TestIndexManager(t *testing.T) {
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(indexManager.String()))
|
||||
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client()))
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
})
|
||||
|
||||
t.Run("fails to create index if it already exists but is not up to date", func(t *testing.T) {
|
||||
t.Run("fails when the analysis settings drift", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := sjson.Set(indexManager.String(), "settings.analysis.analyzer.lowercaseKeyword.tokenizer", "standard")
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()), opensearch.ErrManualActionRequired)
|
||||
})
|
||||
|
||||
t.Run("tolerates replica drift", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := sjson.Set(indexManager.String(), "settings.number_of_replicas", "2")
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
})
|
||||
|
||||
t.Run("tolerates shard drift", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
@@ -58,6 +105,94 @@ func TestIndexManager(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client()), opensearch.ErrManualActionRequired)
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
})
|
||||
|
||||
t.Run("is idempotent", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
})
|
||||
|
||||
t.Run("adds a new field to an existing index in place", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := sjson.Delete(indexManager.String(), "mappings.properties.Title")
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
|
||||
resp, err := tc.Client().Indices.Mapping.Get(t.Context(), &opensearchgoAPI.MappingGetReq{Indices: []string{indexName}})
|
||||
require.NoError(t, err)
|
||||
require.True(t, gjson.GetBytes(resp.Indices[indexName].Mappings, "properties.Title").Exists())
|
||||
})
|
||||
|
||||
t.Run("adds a new nested field to an existing index in place", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := sjson.Delete(indexManager.String(), "mappings.properties.photo.properties.cameraMake")
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.NoError(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()))
|
||||
|
||||
resp, err := tc.Client().Indices.Mapping.Get(t.Context(), &opensearchgoAPI.MappingGetReq{Indices: []string{indexName}})
|
||||
require.NoError(t, err)
|
||||
require.True(t, gjson.GetBytes(resp.Indices[indexName].Mappings, "properties.photo.properties.cameraMake").Exists())
|
||||
})
|
||||
|
||||
t.Run("fails when an existing field changed its definition", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := sjson.Set(indexManager.String(), "mappings.properties.Deleted.type", "keyword")
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()), opensearch.ErrManualActionRequired)
|
||||
})
|
||||
|
||||
t.Run("fails when the index contains a field the code schema does not know", func(t *testing.T) {
|
||||
indexManager := opensearch.IndexManagerLatest
|
||||
indexName := "opencloud-test-resource"
|
||||
|
||||
tc := opensearchtest.NewDefaultTestClient(t, defaultConfig.Engine.OpenSearch.Client)
|
||||
tc.Require.IndicesReset([]string{indexName})
|
||||
|
||||
body, err := sjson.Set(indexManager.String(), "mappings.properties.legacyField.type", "keyword")
|
||||
require.NoError(t, err)
|
||||
tc.Require.IndicesCreate(indexName, strings.NewReader(body))
|
||||
|
||||
require.ErrorIs(t, indexManager.Apply(t.Context(), indexName, tc.Client(), log.NopLogger()), opensearch.ErrManualActionRequired)
|
||||
})
|
||||
|
||||
t.Run("transport errors do not demand manual action", func(t *testing.T) {
|
||||
client, err := opensearchgoAPI.NewClient(opensearchgoAPI.Config{
|
||||
Client: opensearchgo.Config{
|
||||
Addresses: []string{"http://localhost:1025"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = opensearch.IndexManagerLatest.Apply(t.Context(), "opencloud-test-resource", client, log.NopLogger())
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, opensearch.ErrManualActionRequired)
|
||||
})
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
)
|
||||
|
||||
func ExpandKQL(nodes []ast.Node) ([]ast.Node, error) {
|
||||
return kqlExpander{}.expand(nodes, "")
|
||||
}
|
||||
|
||||
type kqlExpander struct{}
|
||||
|
||||
func (e kqlExpander) expand(nodes []ast.Node, defaultKey string) ([]ast.Node, error) {
|
||||
for i, node := range nodes {
|
||||
rnode := reflect.ValueOf(node)
|
||||
|
||||
// we need to ensure that the node is a pointer to an ast.Node in every case
|
||||
if rnode.Kind() != reflect.Ptr {
|
||||
ptr := reflect.New(rnode.Type())
|
||||
ptr.Elem().Set(rnode)
|
||||
rnode = ptr
|
||||
cnode, ok := rnode.Interface().(ast.Node)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected node to be of type ast.Node, got %T", rnode.Interface())
|
||||
}
|
||||
|
||||
node = cnode // Update the original node to the pointer
|
||||
nodes[i] = node // Update the original slice with the pointer
|
||||
}
|
||||
|
||||
var unfoldedNodes []ast.Node
|
||||
switch cnode := node.(type) {
|
||||
case *ast.GroupNode:
|
||||
if cnode.Key != "" { // group nodes should not get a default key
|
||||
cnode.Key = e.remapKey(cnode.Key, defaultKey)
|
||||
}
|
||||
|
||||
groupNodes, err := e.expand(cnode.Nodes, cnode.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cnode.Nodes = groupNodes
|
||||
case *ast.StringNode:
|
||||
cnode.Key = e.remapKey(cnode.Key, defaultKey)
|
||||
cnode.Value = e.lowerValue(cnode.Key, cnode.Value)
|
||||
unfoldedNodes = e.unfoldValue(cnode.Key, cnode.Value)
|
||||
case *ast.DateTimeNode:
|
||||
cnode.Key = e.remapKey(cnode.Key, defaultKey)
|
||||
case *ast.BooleanNode:
|
||||
cnode.Key = e.remapKey(cnode.Key, defaultKey)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,607 +0,0 @@
|
||||
package convert_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/convert"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/services/search/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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,12 +18,10 @@ func KQLToOpenSearchBoolQuery(kqlQuery string) (*osu.BoolQuery, error) {
|
||||
return nil, 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)
|
||||
}
|
||||
// shared lowering: field resolution, media-type expansion, value lowercasing.
|
||||
kqlAst = query.Normalize(kqlAst, query.ResolveField)
|
||||
|
||||
builder, err := TranspileKQLToOpenSearch(kqlNodes)
|
||||
builder, err := TranspileKQLToOpenSearch(kqlAst.Nodes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile query: %w", err)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
)
|
||||
|
||||
func TranspileKQLToOpenSearch(nodes []ast.Node) (osu.Builder, error) {
|
||||
@@ -46,13 +48,21 @@ func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error
|
||||
nextOp := t.getOperatorValueAt(nodes, i+1)
|
||||
prevOp := t.getOperatorValueAt(nodes, i-1)
|
||||
|
||||
// A preceding NOT negates this node regardless of what follows (NOT x AND y
|
||||
// is (NOT x) AND y), so it must win over nextOp. The prevOp AND/OR cases
|
||||
// give the right operand its own bucket instead of inheriting the previous
|
||||
// one, which matters right after a NOT (its MustNot must not carry over).
|
||||
switch {
|
||||
case prevOp == kql.BoolNOT:
|
||||
boolQueryAdd = boolQuery.MustNot
|
||||
case nextOp == kql.BoolOR:
|
||||
boolQueryAdd = boolQuery.Should
|
||||
case nextOp == kql.BoolAND:
|
||||
boolQueryAdd = boolQuery.Must
|
||||
case prevOp == kql.BoolNOT:
|
||||
boolQueryAdd = boolQuery.MustNot
|
||||
case prevOp == kql.BoolOR:
|
||||
boolQueryAdd = boolQuery.Should
|
||||
case prevOp == kql.BoolAND:
|
||||
boolQueryAdd = boolQuery.Must
|
||||
}
|
||||
|
||||
builder, err := t.toBuilder(node)
|
||||
@@ -69,7 +79,7 @@ func (t kqlOpensearchTranspiler) transpile(nodes []ast.Node) (osu.Builder, error
|
||||
continue
|
||||
}
|
||||
|
||||
if nextOp == kql.BoolOR {
|
||||
if nextOp == kql.BoolOR || prevOp == kql.BoolOR {
|
||||
// if there are should clauses, we set the minimum should match to 1
|
||||
boolQueryParams.MinimumShouldMatch = 1
|
||||
}
|
||||
@@ -99,22 +109,39 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
|
||||
case *ast.BooleanNode:
|
||||
return osu.NewTermQuery[bool](node.Key).Value(node.Value), nil
|
||||
case *ast.StringNode:
|
||||
isWildcard := strings.Contains(node.Value, "*")
|
||||
if isWildcard {
|
||||
return osu.NewWildcardQuery(node.Key).Value(node.Value), nil
|
||||
field, value := node.Key, node.Value
|
||||
if node.CaseInsensitive {
|
||||
field += mapping.LowercaseSuffix
|
||||
value = strings.ToLower(value)
|
||||
}
|
||||
|
||||
totalTerms := strings.Split(node.Value, " ")
|
||||
isWildcard := strings.Contains(value, "*")
|
||||
if isWildcard {
|
||||
return osu.NewWildcardQuery(field).Value(value), nil
|
||||
}
|
||||
|
||||
if query.FieldIsFulltext(node.Key) {
|
||||
return osu.NewMatchPhraseQuery(field).Query(value), nil
|
||||
}
|
||||
|
||||
// a path value is a single term in the path_hierarchy token stream; a
|
||||
// phrase match would analyze the query into its path prefixes and match
|
||||
// everything under the root, so paths with spaces must stay term queries.
|
||||
if query.FieldIsPath(node.Key) {
|
||||
return osu.NewTermQuery[string](field).Value(value), nil
|
||||
}
|
||||
|
||||
totalTerms := strings.Split(value, " ")
|
||||
isSingleTerm := len(totalTerms) == 1
|
||||
isMultiTerm := len(totalTerms) >= 1
|
||||
switch {
|
||||
case isSingleTerm:
|
||||
return osu.NewTermQuery[string](node.Key).Value(node.Value), nil
|
||||
return osu.NewTermQuery[string](field).Value(value), nil
|
||||
case isMultiTerm:
|
||||
return osu.NewMatchPhraseQuery(node.Key).Query(node.Value), nil
|
||||
return osu.NewMatchPhraseQuery(field).Query(value), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported string node value: %s", node.Value)
|
||||
return nil, fmt.Errorf("unsupported string node value: %s", value)
|
||||
case *ast.DateTimeNode:
|
||||
if node.Operator == nil {
|
||||
return builder, fmt.Errorf("date time node without operator: %w", ErrUnsupportedNodeType)
|
||||
|
||||
@@ -24,6 +24,33 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
|
||||
},
|
||||
Want: osu.NewTermQuery[string]("Name").Value("openCloud"),
|
||||
},
|
||||
{
|
||||
Name: "case-insensitive term routes to the lowercased sibling",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "openCloud", CaseInsensitive: true},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[string]("Name_lowercase").Value("opencloud"),
|
||||
},
|
||||
{
|
||||
Name: "case-insensitive wildcard routes to the lowercased sibling",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "Open*", CaseInsensitive: true},
|
||||
},
|
||||
},
|
||||
Want: osu.NewWildcardQuery("Name_lowercase").Value("open*"),
|
||||
},
|
||||
{
|
||||
Name: "full-text field uses an analyzed match query, not an unanalyzed term",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Content", Value: "Running"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewMatchPhraseQuery("Content").Query("Running"),
|
||||
},
|
||||
{
|
||||
Name: "term query - boolean node - true",
|
||||
Got: &ast.Ast{
|
||||
@@ -60,6 +87,26 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
|
||||
},
|
||||
Want: osu.NewWildcardQuery("Name").Value("open*"),
|
||||
},
|
||||
{
|
||||
// a phrase match would analyze the query with path_hierarchy and match
|
||||
// everything under the root
|
||||
Name: "path with spaces stays an unanalyzed term query",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Path", Value: "./parent d!r/child.pdf"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[string]("Path").Value("./parent d!r/child.pdf"),
|
||||
},
|
||||
{
|
||||
Name: "case-insensitive path with spaces routes to the lowercased sibling as a term query",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Path", Value: "./Parent Dir", CaseInsensitive: true},
|
||||
},
|
||||
},
|
||||
Want: osu.NewTermQuery[string]("Path_lowercase").Value("./parent dir"),
|
||||
},
|
||||
{
|
||||
Name: "bool query",
|
||||
Got: &ast.Ast{
|
||||
@@ -223,6 +270,26 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
),
|
||||
},
|
||||
{
|
||||
// NOT binds to the node directly after it, not to whatever operator
|
||||
// follows that node: NOT x AND y is (NOT x) AND y.
|
||||
Name: "[NOT * AND *]",
|
||||
Got: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "age", Value: "32"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "Name", Value: "openCloud"},
|
||||
},
|
||||
},
|
||||
Want: osu.NewBoolQuery().
|
||||
MustNot(
|
||||
osu.NewTermQuery[string]("age").Value("32"),
|
||||
).
|
||||
Must(
|
||||
osu.NewTermQuery[string]("Name").Value("openCloud"),
|
||||
),
|
||||
},
|
||||
{
|
||||
Name: "[* OR * OR *]",
|
||||
Got: &ast.Ast{
|
||||
|
||||
@@ -3,7 +3,6 @@ package convert
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
@@ -15,6 +14,17 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// copyFacet converts a typed pointer from the indexed shape (libregraph) to
|
||||
// the protobuf shape via conversions.To. Returns nil when src is nil so the
|
||||
// enclosing Match.Entity field stays nil.
|
||||
func copyFacet[Dst, Src any](src *Src) *Dst {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst, _ := conversions.To[*Dst](src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, error) {
|
||||
resource, err := conversions.To[search.Resource](hit.Source)
|
||||
if err != nil {
|
||||
@@ -69,31 +79,15 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match,
|
||||
|
||||
return strings.Join(contentHighlights[:], "; ")
|
||||
}(),
|
||||
Audio: func() *searchMessage.Audio {
|
||||
if !strings.HasPrefix(resource.MimeType, "audio/") {
|
||||
return nil
|
||||
}
|
||||
|
||||
audio, _ := conversions.To[*searchMessage.Audio](resource.Audio)
|
||||
return audio
|
||||
}(),
|
||||
Image: func() *searchMessage.Image {
|
||||
image, _ := conversions.To[*searchMessage.Image](resource.Image)
|
||||
return image
|
||||
}(),
|
||||
Location: func() *searchMessage.GeoCoordinates {
|
||||
geoCoordinates, _ := conversions.To[*searchMessage.GeoCoordinates](resource.Location)
|
||||
return geoCoordinates
|
||||
}(),
|
||||
Photo: func() *searchMessage.Photo {
|
||||
photo, _ := conversions.To[*searchMessage.Photo](resource.Photo)
|
||||
return photo
|
||||
}(),
|
||||
Audio: copyFacet[searchMessage.Audio](resource.Audio),
|
||||
Image: copyFacet[searchMessage.Image](resource.Image),
|
||||
Location: copyFacet[searchMessage.GeoCoordinates](resource.Location),
|
||||
Photo: copyFacet[searchMessage.Photo](resource.Photo),
|
||||
},
|
||||
}
|
||||
|
||||
if mtime, err := time.Parse(time.RFC3339, resource.Mtime); err == nil {
|
||||
match.Entity.LastModifiedTime = ×tamppb.Timestamp{Seconds: mtime.Unix(), Nanos: int32(mtime.Nanosecond())}
|
||||
if resource.Mtime != nil {
|
||||
match.Entity.LastModifiedTime = timestamppb.New(*resource.Mtime)
|
||||
}
|
||||
|
||||
return match, nil
|
||||
|
||||
@@ -36,7 +36,7 @@ var _ = Describe("OpenSearchHitToMatch", func() {
|
||||
resource = opensearchtest.Testdata.Resources.File
|
||||
resource.MimeType = "audio/mpeg"
|
||||
mtime = time.Date(2025, 7, 24, 15, 15, 1, 0, time.UTC)
|
||||
resource.Mtime = mtime.Format(time.RFC3339)
|
||||
resource.Mtime = &mtime
|
||||
resource.Favorites = []string{"cbf24bce-3e6e-4d9e-a2a2-cbf24bce3e6e"}
|
||||
|
||||
hit = opensearchgoAPI.SearchHit{
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"settings": {
|
||||
"number_of_shards": "1",
|
||||
"number_of_replicas": "1",
|
||||
"analysis": {
|
||||
"analyzer": {
|
||||
"path_hierarchy": {
|
||||
"filter": [
|
||||
"lowercase"
|
||||
],
|
||||
"tokenizer": "path_hierarchy",
|
||||
"type": "custom"
|
||||
}
|
||||
},
|
||||
"tokenizer": {
|
||||
"path_hierarchy": {
|
||||
"type": "path_hierarchy"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"ID": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"ParentID": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"RootID": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"MimeType": {
|
||||
"type": "wildcard",
|
||||
"doc_values": false
|
||||
},
|
||||
"Path": {
|
||||
"type": "text",
|
||||
"analyzer": "path_hierarchy"
|
||||
},
|
||||
"Deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Hidden": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"settings": {
|
||||
"number_of_shards": "1",
|
||||
"number_of_replicas": "1",
|
||||
"analysis": {
|
||||
"analyzer": {
|
||||
"path_hierarchy": {
|
||||
"filter": [
|
||||
"lowercase"
|
||||
],
|
||||
"tokenizer": "path_hierarchy",
|
||||
"type": "custom"
|
||||
}
|
||||
},
|
||||
"tokenizer": {
|
||||
"path_hierarchy": {
|
||||
"type": "path_hierarchy"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"Content": {
|
||||
"type": "text",
|
||||
"term_vector": "with_positions_offsets"
|
||||
},
|
||||
"ID": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"ParentID": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"RootID": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"MimeType": {
|
||||
"type": "wildcard",
|
||||
"doc_values": false
|
||||
},
|
||||
"Path": {
|
||||
"type": "text",
|
||||
"analyzer": "path_hierarchy"
|
||||
},
|
||||
"Deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Hidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"Favorites": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,15 @@ func setupOpenSearchTestContainer(ctx context.Context, cfg *config.Config) (func
|
||||
opensearch.WithPassword(cfg.Engine.OpenSearch.Client.Password),
|
||||
testcontainers.WithName(containerName),
|
||||
testcontainers.WithReuseByName(containerName),
|
||||
// test indexes are tiny; don't let a full host disk trip the flood-stage
|
||||
// create-index / read-only blocks mid-run
|
||||
testcontainers.WithEnv(map[string]string{
|
||||
"cluster.routing.allocation.disk.threshold_enabled": "false",
|
||||
}),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("ML configuration initialized successfully").
|
||||
WithStartupTimeout(5*time.Second),
|
||||
// a cold OpenSearch boot takes well over the previous 5s
|
||||
WithStartupTimeout(2*time.Minute),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"Name" : "dummy name",
|
||||
"Content" : "dummy content",
|
||||
"Size" : 42,
|
||||
"Mtime" : "2025-07-24 15:15:01.324093 +0200 CEST m=+0.000056251",
|
||||
"Mtime" : "2025-07-24T15:15:01.324093+02:00",
|
||||
"MimeType" : "image/jpeg",
|
||||
"Tags" : [ "dummy" ],
|
||||
"Deleted" : false,
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"RootID" : "1$1!1",
|
||||
"ParentID" : "1$1!1",
|
||||
"Path" : "./parent d!r",
|
||||
"Type" : 2
|
||||
"Type" : 2,
|
||||
"Mtime" : "2025-07-24T15:15:01.324093+02:00"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ID" : "1$1!1",
|
||||
"RootID" : "1$1!1",
|
||||
"Path" : "."
|
||||
"Path" : ".",
|
||||
"Mtime" : "2025-07-24T15:15:01.324093+02:00"
|
||||
}
|
||||
@@ -22,6 +22,10 @@ func (c Creator[T]) Create(qs string) (T, error) {
|
||||
return t, err
|
||||
}
|
||||
|
||||
// shared KQL lowering pass: resolve field names + expand media-type aliases
|
||||
// once, so the compiler below sees only canonical field:value nodes.
|
||||
builderAst = query.Normalize(builderAst, query.ResolveField)
|
||||
|
||||
t, err = c.compiler.Compile(builderAst)
|
||||
if err != nil {
|
||||
return t, err
|
||||
|
||||
@@ -8,35 +8,10 @@ import (
|
||||
bleveQuery "github.com/blevesearch/bleve/v2/search/query"
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
searchQuery "github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -96,26 +71,30 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
||||
for i := offset; i < len(nodes); i++ {
|
||||
switch n := nodes[i].(type) {
|
||||
case *ast.StringNode:
|
||||
k := getField(n.Key)
|
||||
// keys are resolved and media-type expanded by normalize. MimeType
|
||||
// skips the escaper so the category wildcards (image/*) keep their `*`;
|
||||
// bleve treats `/` and `+` as literals mid-term, so a literal MIME like
|
||||
// image/svg+xml still matches exactly.
|
||||
k := n.Key
|
||||
v := n.Value
|
||||
if k != "ID" && k != "Size" {
|
||||
if k != "ID" && k != "Size" && k != "MimeType" {
|
||||
v = bleveEscaper.Replace(n.Value)
|
||||
}
|
||||
|
||||
if _, ok := lowercaseFields[k]; ok {
|
||||
if n.CaseInsensitive {
|
||||
k += mapping.LowercaseSuffix
|
||||
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)
|
||||
var q bleveQuery.Query = bleveQuery.NewQueryStringQuery(k + ":" + v)
|
||||
if searchQuery.FieldIsPath(n.Key) {
|
||||
// bleve has no path hierarchy analyzer, unlike OpenSearch: match the
|
||||
// folder itself and its descendants (`\/*`). A BooleanQuery keeps
|
||||
// this atomic; a DisjunctionQuery would be redistributed by an
|
||||
// enclosing AND (mapBinary treats a left disjunction as an OR-chain).
|
||||
bq := bleve.NewBooleanQuery()
|
||||
bq.AddShould(q, bleveQuery.NewQueryStringQuery(k+":"+v+`\/*`))
|
||||
bq.SetMinShould(1)
|
||||
q = bq
|
||||
}
|
||||
|
||||
if prev == nil {
|
||||
@@ -129,7 +108,7 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
||||
End: bleveQuery.BleveQueryTime{},
|
||||
InclusiveStart: nil,
|
||||
InclusiveEnd: nil,
|
||||
FieldVal: getField(n.Key),
|
||||
FieldVal: n.Key,
|
||||
}
|
||||
|
||||
if n.Operator == nil {
|
||||
@@ -159,16 +138,14 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
||||
next = q
|
||||
}
|
||||
case *ast.BooleanNode:
|
||||
q := bleveQuery.NewQueryStringQuery(getField(n.Key) + fmt.Sprintf(":%v", n.Value))
|
||||
q := bleveQuery.NewQueryStringQuery(n.Key + fmt.Sprintf(":%v", n.Value))
|
||||
if prev == nil {
|
||||
prev = q
|
||||
} else {
|
||||
next = q
|
||||
}
|
||||
case *ast.GroupNode:
|
||||
if n.Key != "" {
|
||||
n = normalizeGroupingProperty(n)
|
||||
}
|
||||
// keys resolved and grouping property propagated in normalize
|
||||
q, _, err := walk(0, n.Nodes)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -191,8 +168,11 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
|
||||
q := bleve.NewBooleanQuery()
|
||||
q.AddMustNot(next)
|
||||
if prev == nil {
|
||||
// unary in the beginning
|
||||
// unary at the beginning: the term was consumed into the
|
||||
// MustNot via nextNode, so clear next, otherwise a following
|
||||
// operator would bind the stale term (NOT x AND y drops y).
|
||||
prev = q
|
||||
next = nil
|
||||
} else {
|
||||
next = q
|
||||
}
|
||||
@@ -276,90 +256,3 @@ func mapBinary(operator *ast.OperatorNode, ln, rn bleveQuery.Query, leftIsGroup
|
||||
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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
searchquery "github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
tAssert "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,11 @@ var timeMustParse = func(t *testing.T, ts string) time.Time {
|
||||
return tp
|
||||
}
|
||||
|
||||
// TODO(followup): make this a pure compiler test. Field resolution and
|
||||
// media-type expansion live in query.Normalize, so this test could feed
|
||||
// canonical ASTs (real field names, media-type already expanded) and call
|
||||
// compile() directly, dropping the query.Normalize wrapper and the mediatype
|
||||
// cases.
|
||||
func Test_compile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -33,10 +39,31 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:federated`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:federated`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
// path fields expand to match the folder itself and its descendants,
|
||||
// since bleve has no path hierarchy analyzer.
|
||||
name: `path:/Foo`,
|
||||
args: &ast.Ast{
|
||||
Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "path", Value: "/Foo"},
|
||||
},
|
||||
},
|
||||
// a BooleanQuery (should: exact OR descendants), not a DisjunctionQuery,
|
||||
// so an enclosing AND does not redistribute the folder-itself clause.
|
||||
want: func() query.Query {
|
||||
bq := query.NewBooleanQuery(nil, []query.Query{
|
||||
query.NewQueryStringQuery(`Path:\/Foo`),
|
||||
query.NewQueryStringQuery(`Path:\/Foo\/*`),
|
||||
}, nil)
|
||||
bq.SetMinShould(1)
|
||||
return query.NewConjunctionQuery([]query.Query{bq})
|
||||
}(),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: `"John Smith"`,
|
||||
args: &ast.Ast{
|
||||
@@ -45,7 +72,7 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:john\ smith`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -59,8 +86,8 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith`),
|
||||
query.NewQueryStringQuery(`Name:jane`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:john\ smith`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:jane`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -74,8 +101,8 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:book`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -91,10 +118,10 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:moby\ di*`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:book`),
|
||||
}),
|
||||
}),
|
||||
wantErr: false,
|
||||
@@ -112,10 +139,10 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:a`),
|
||||
query.NewQueryStringQuery(`Name:b`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:a`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:b`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:c`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:c`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -131,10 +158,10 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:a`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:a`),
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:b`),
|
||||
query.NewQueryStringQuery(`Name:c`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:b`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:c`),
|
||||
}),
|
||||
}),
|
||||
wantErr: false,
|
||||
@@ -156,11 +183,11 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:a`),
|
||||
query.NewQueryStringQuery(`Name:b`),
|
||||
query.NewQueryStringQuery(`Name:c`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:a`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:b`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:c`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:d`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:d`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -179,10 +206,10 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:moby\ di*`),
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:book`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -204,11 +231,11 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewDisjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:moby\ di*`),
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:moby\ di*`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Tags:book`),
|
||||
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:read`)}),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:book`),
|
||||
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags_lowercase:read`)}),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -251,7 +278,7 @@ func Test_compile(t *testing.T) {
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`author:John\ Smith`),
|
||||
query.NewQueryStringQuery(`author:Jane`),
|
||||
query.NewQueryStringQuery(`Tags:bestseller`),
|
||||
query.NewQueryStringQuery(`Tags_lowercase:bestseller`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -293,7 +320,7 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:john\ smith`),
|
||||
query.NewQueryStringQuery(`Hidden:T`),
|
||||
query.NewQueryStringQuery(`Hidden:T`),
|
||||
}),
|
||||
@@ -308,7 +335,7 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags:physik`)}),
|
||||
query.NewBooleanQuery(nil, nil, []query.Query{query.NewQueryStringQuery(`Tags_lowercase:physik`)}),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -424,7 +451,7 @@ func Test_compile(t *testing.T) {
|
||||
query.NewQueryStringQuery(`MimeType:application/rtf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -450,7 +477,7 @@ func Test_compile(t *testing.T) {
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`MimeType:application/pdf`),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
|
||||
}),
|
||||
}),
|
||||
wantErr: false,
|
||||
@@ -480,7 +507,7 @@ func Test_compile(t *testing.T) {
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
query.NewQueryStringQuery(`MimeType:application/pdf`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -509,7 +536,7 @@ func Test_compile(t *testing.T) {
|
||||
query.NewQueryStringQuery(`MimeType:application/rtf`),
|
||||
query.NewQueryStringQuery(`MimeType:application/vnd.apple.pages`),
|
||||
}),
|
||||
query.NewQueryStringQuery(`Name:*tdd*`),
|
||||
query.NewQueryStringQuery(`Name_lowercase:*tdd*`),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -521,7 +548,7 @@ func Test_compile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: query.NewConjunctionQuery([]query.Query{
|
||||
query.NewQueryStringQuery(`Name:john\ smith\ \+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\ `),
|
||||
query.NewQueryStringQuery(`Name_lowercase:john\ smith\ \+\-\=\&\|\>\<\!\(\)\{\}\[\]\^\"\~\:\ `),
|
||||
}),
|
||||
wantErr: false,
|
||||
},
|
||||
@@ -531,7 +558,7 @@ func Test_compile(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := compile(tt.args)
|
||||
got, err := compile(searchquery.Normalize(tt.args, searchquery.ResolveField))
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("compile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Package mimetype maps the "mediatype" KQL restriction (field name and value)
|
||||
// to a concrete MimeType query.
|
||||
package mimetype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
)
|
||||
|
||||
// field is the real index field a mediatype restriction targets.
|
||||
const field = "MimeType"
|
||||
|
||||
// Expand turns mediatype:<value> into the MimeType query it stands for: category
|
||||
// values (file/document/image/...) expand to their MIME set, anything else is a
|
||||
// literal MimeType:<value>. Returns nil for non-mediatype keys. Categories and
|
||||
// MIME types are case-insensitive, so the value is lowercased.
|
||||
func Expand(key, value string) []ast.Node {
|
||||
if strings.ToLower(key) != "mediatype" {
|
||||
return nil
|
||||
}
|
||||
value = strings.ToLower(value)
|
||||
switch value {
|
||||
case "file":
|
||||
return []ast.Node{
|
||||
&ast.OperatorNode{Value: kql.BoolNOT},
|
||||
&ast.StringNode{Key: field, Value: "httpd/unix-directory"},
|
||||
}
|
||||
case "folder":
|
||||
return term("httpd/unix-directory")
|
||||
case "document":
|
||||
return group(
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/rtf",
|
||||
"application/vnd.apple.pages",
|
||||
)
|
||||
case "spreadsheet":
|
||||
return group(
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/csv",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.apple.numbers",
|
||||
)
|
||||
case "presentation":
|
||||
return group(
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.apple.keynote",
|
||||
)
|
||||
case "pdf":
|
||||
return term("application/pdf")
|
||||
case "image":
|
||||
return term("image/*")
|
||||
case "video":
|
||||
return term("video/*")
|
||||
case "audio":
|
||||
return term("audio/*")
|
||||
case "archive":
|
||||
return group(
|
||||
"application/zip",
|
||||
"application/gzip",
|
||||
"application/x-gzip",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-tar",
|
||||
"application/x-bzip2",
|
||||
"application/x-bzip",
|
||||
"application/x-tgz",
|
||||
)
|
||||
}
|
||||
// not a category: treat the value as a literal MIME type.
|
||||
return term(value)
|
||||
}
|
||||
|
||||
// term is a single MimeType:value restriction.
|
||||
func term(value string) []ast.Node {
|
||||
return []ast.Node{&ast.StringNode{Key: field, Value: value}}
|
||||
}
|
||||
|
||||
// group is a single OR group of MimeType:value restrictions.
|
||||
func group(values ...string) []ast.Node {
|
||||
nodes := make([]ast.Node, 0, len(values)*2-1)
|
||||
for i, v := range values {
|
||||
if i > 0 {
|
||||
nodes = append(nodes, &ast.OperatorNode{Value: kql.BoolOR})
|
||||
}
|
||||
nodes = append(nodes, &ast.StringNode{Key: field, Value: v})
|
||||
}
|
||||
return []ast.Node{&ast.GroupNode{Nodes: nodes}}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package mimetype_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMimetype(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Mimetype Suite")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package mimetype_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query/mimetype"
|
||||
)
|
||||
|
||||
// This is the single place the mediatype -> MimeType mapping is tested. The
|
||||
// query pipeline consumes Expand via query.Normalize and must NOT re-test it.
|
||||
|
||||
// mimeValues extracts the StringNode values from an OR group, dropping operators.
|
||||
func mimeValues(group *ast.GroupNode) []string {
|
||||
var out []string
|
||||
for _, n := range group.Nodes {
|
||||
if s, ok := n.(*ast.StringNode); ok {
|
||||
out = append(out, s.Value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var _ = Describe("Expand", func() {
|
||||
It("only triggers on the mediatype key", func() {
|
||||
Expect(mimetype.Expand("Name", "document")).To(BeNil())
|
||||
Expect(mimetype.Expand("MimeType", "file")).To(BeNil()) // the real field name is not the trigger
|
||||
Expect(mimetype.Expand("Tags", "file")).To(BeNil())
|
||||
})
|
||||
|
||||
It("matches the key case-insensitively", func() {
|
||||
Expect(mimetype.Expand("MediaType", "file")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("matches the value case-insensitively", func() {
|
||||
// a category matches regardless of case
|
||||
Expect(mimetype.Expand("mediatype", "Folder")).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
|
||||
}))
|
||||
// a literal MIME type is lowercased too (MIME types are case-insensitive)
|
||||
Expect(mimetype.Expand("mediatype", "Image/SVG+XML")).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "MimeType", Value: "image/svg+xml"},
|
||||
}))
|
||||
})
|
||||
|
||||
// A non-category value is a literal MIME type and targets the MimeType field.
|
||||
It("passes literal values through to MimeType", func() {
|
||||
Expect(mimetype.Expand("mediatype", "application/pdf")).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "MimeType", Value: "application/pdf"},
|
||||
}))
|
||||
Expect(mimetype.Expand("mediatype", "image/jpeg")).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "MimeType", Value: "image/jpeg"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("expands file to not-a-folder", func() {
|
||||
Expect(mimetype.Expand("mediatype", "file")).To(Equal([]ast.Node{
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("expands folder to a single term", func() {
|
||||
Expect(mimetype.Expand("mediatype", "folder")).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("expands wildcard categories", func() {
|
||||
for value, mime := range map[string]string{
|
||||
"image": "image/*", "video": "video/*", "audio": "audio/*", "pdf": "application/pdf",
|
||||
} {
|
||||
Expect(mimetype.Expand("mediatype", value)).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "MimeType", Value: mime},
|
||||
}), value)
|
||||
}
|
||||
})
|
||||
|
||||
It("expands the document group", func() {
|
||||
got := mimetype.Expand("mediatype", "document")
|
||||
Expect(got).To(HaveLen(1))
|
||||
group, ok := got[0].(*ast.GroupNode)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(mimeValues(group)).To(Equal([]string{
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.form",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"application/rtf",
|
||||
"application/vnd.apple.pages",
|
||||
}))
|
||||
})
|
||||
|
||||
// spreadsheet asserts the exact MIME set, in order, with no duplicate entry.
|
||||
It("expands the spreadsheet group", func() {
|
||||
group := mimetype.Expand("mediatype", "spreadsheet")[0].(*ast.GroupNode)
|
||||
Expect(mimeValues(group)).To(Equal([]string{
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/csv",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.apple.numbers",
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query/mimetype"
|
||||
)
|
||||
|
||||
// Normalize is the shared KQL lowering pass between parse and compile: it
|
||||
// resolves keys to real field names (via resolve) and expands media-type
|
||||
// restrictions, so the backends compile a plain field:value AST.
|
||||
func Normalize(a *ast.Ast, resolve func(string) string) *ast.Ast {
|
||||
a.Nodes = normalizeNodes(a.Nodes, resolve, "")
|
||||
return a
|
||||
}
|
||||
|
||||
// normalizeNodes rewrites nodes in place. defaultKey is what a bare restriction
|
||||
// inherits: its enclosing group's key, or "" at the top level.
|
||||
func normalizeNodes(nodes []ast.Node, resolve func(string) string, defaultKey string) []ast.Node {
|
||||
resolveKey := func(key string) string {
|
||||
if key == "" && defaultKey != "" {
|
||||
return defaultKey // bare child inherits the group key
|
||||
}
|
||||
return resolve(key)
|
||||
}
|
||||
|
||||
out := make([]ast.Node, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
n = toPointer(n) // ensure a pointer so in-place key rewrites persist
|
||||
switch node := n.(type) {
|
||||
case *ast.StringNode:
|
||||
node.Key = resolveKey(node.Key)
|
||||
if exp := mimetype.Expand(node.Key, node.Value); exp != nil {
|
||||
out = append(out, normalizeNodes(exp, resolve, defaultKey)...)
|
||||
continue
|
||||
}
|
||||
node.CaseInsensitive = FieldIsCaseInsensitive(node.Key)
|
||||
out = append(out, node)
|
||||
case *ast.DateTimeNode:
|
||||
node.Key = resolveKey(node.Key)
|
||||
out = append(out, node)
|
||||
case *ast.BooleanNode:
|
||||
node.Key = resolveKey(node.Key)
|
||||
out = append(out, node)
|
||||
case *ast.GroupNode:
|
||||
groupKey := defaultKey
|
||||
if node.Key != "" {
|
||||
node.Key = resolve(node.Key)
|
||||
groupKey = node.Key
|
||||
}
|
||||
node.Nodes = normalizeNodes(node.Nodes, resolve, groupKey)
|
||||
out = append(out, node)
|
||||
default:
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toPointer returns n as a pointer; the parser emits some nodes by value and the
|
||||
// in-place key rewrites would be lost on those.
|
||||
func toPointer(n ast.Node) ast.Node {
|
||||
rv := reflect.ValueOf(n)
|
||||
if rv.Kind() == reflect.Ptr {
|
||||
return n
|
||||
}
|
||||
ptr := reflect.New(rv.Type())
|
||||
ptr.Elem().Set(rv)
|
||||
if pn, ok := ptr.Interface().(ast.Node); ok {
|
||||
return pn
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package query_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
|
||||
)
|
||||
|
||||
// This is the single place the shared KQL lowering pass is tested (field
|
||||
// resolution, media-type expansion, group-key defaulting, pointer conversion).
|
||||
// The backend query compilers consume its canonical output and must not re-test
|
||||
// it.
|
||||
|
||||
func norm(nodes ...ast.Node) []ast.Node {
|
||||
return query.Normalize(&ast.Ast{Nodes: nodes}, query.ResolveField).Nodes
|
||||
}
|
||||
|
||||
var _ = Describe("ResolveField", func() {
|
||||
It("resolves keys to canonical field names", func() {
|
||||
Expect(query.ResolveField("")).To(Equal("Name")) // empty -> free-text default
|
||||
Expect(query.ResolveField("NAME")).To(Equal("Name")) // canonical, case-insensitive key match
|
||||
Expect(query.ResolveField("tag")).To(Equal("Tags")) // singular alias
|
||||
Expect(query.ResolveField("mimetype")).To(Equal("MimeType")) // real field
|
||||
Expect(query.ResolveField("photo.CAMERAMAKE")).To(Equal("photo.cameraMake")) // facet, case-insensitive key match
|
||||
Expect(query.ResolveField("unknown.field")).To(Equal("unknown.field")) // unknown key: unchanged, becomes a dead query
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("FieldIsCaseInsensitive", func() {
|
||||
It("reports the CaseInsensitive override fields", func() {
|
||||
// The three CaseInsensitive override fields (resolved canonical names).
|
||||
for _, f := range []string{"Name", "Tags", "Favorites"} {
|
||||
Expect(query.FieldIsCaseInsensitive(f)).To(BeTrue(), f)
|
||||
}
|
||||
// Case-preserved / non-keyword fields are not.
|
||||
for _, f := range []string{"MimeType", "ID", "Content", "Path", "unknown"} {
|
||||
Expect(query.FieldIsCaseInsensitive(f)).To(BeFalse(), f)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Normalize", func() {
|
||||
It("resolves fields and expands mediatype", func() {
|
||||
got := norm(
|
||||
&ast.StringNode{Key: "", Value: "free"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "TAG", Value: "x"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "photo.cameramake", Value: "canon"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "mediatype", Value: "file"},
|
||||
)
|
||||
Expect(got).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "free", CaseInsensitive: true},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "Tags", Value: "x", CaseInsensitive: true},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.StringNode{Key: "photo.cameraMake", Value: "canon"},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.OperatorNode{Value: "NOT"},
|
||||
&ast.StringNode{Key: "MimeType", Value: "httpd/unix-directory"},
|
||||
}))
|
||||
})
|
||||
|
||||
// A bare restriction inside a named group inherits the group key; a keyed
|
||||
// child keeps its own key; a bare restriction in an unnamed group falls
|
||||
// back to Name.
|
||||
It("defaults group keys", func() {
|
||||
got := norm(
|
||||
&ast.GroupNode{Key: "author", Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "b"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "name", Value: "d"},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Value: "e"},
|
||||
}},
|
||||
)
|
||||
Expect(got).To(Equal([]ast.Node{
|
||||
&ast.GroupNode{Key: "author", Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "author", Value: "b"},
|
||||
&ast.OperatorNode{Value: "OR"},
|
||||
&ast.StringNode{Key: "Name", Value: "d", CaseInsensitive: true},
|
||||
}},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.GroupNode{Nodes: []ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "e", CaseInsensitive: true},
|
||||
}},
|
||||
}))
|
||||
})
|
||||
|
||||
It("converts value nodes to pointers", func() {
|
||||
got := norm(
|
||||
ast.StringNode{Key: "name", Value: "x"},
|
||||
ast.OperatorNode{Value: "AND"},
|
||||
ast.DateTimeNode{Key: "mtime"},
|
||||
)
|
||||
Expect(got).To(Equal([]ast.Node{
|
||||
&ast.StringNode{Key: "Name", Value: "x", CaseInsensitive: true},
|
||||
&ast.OperatorNode{Value: "AND"},
|
||||
&ast.DateTimeNode{Key: "Mtime"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
package query_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestQuery(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Query Suite")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
|
||||
)
|
||||
|
||||
// aliases are KQL spellings the derived index can't produce (fields are plural).
|
||||
var aliases = map[string]string{
|
||||
"tag": "Tags",
|
||||
"favorite": "Favorites",
|
||||
}
|
||||
|
||||
// fieldIndex maps a lowercased KQL key to its canonical field name ("" is the
|
||||
// bare-search default).
|
||||
var fieldIndex = sync.OnceValue(func() map[string]string {
|
||||
idx := mapping.FieldNameIndex(reflect.TypeFor[search.Resource](), search.Resource{}.SearchFieldOverrides())
|
||||
for k, v := range aliases {
|
||||
idx[k] = v
|
||||
}
|
||||
idx[""] = idx["name"]
|
||||
return idx
|
||||
})
|
||||
|
||||
// caseInsensitiveFields are the fields searched case-insensitively by default,
|
||||
// derived from the CaseInsensitive overrides.
|
||||
var caseInsensitiveFields = sync.OnceValue(func() map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
|
||||
if opts.CaseInsensitive != nil && *opts.CaseInsensitive {
|
||||
out[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// pathFields are hierarchical path fields (TypePath), derived from the overrides.
|
||||
var pathFields = sync.OnceValue(func() map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
|
||||
if opts.Type == mapping.TypePath {
|
||||
out[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// fulltextFields are analyzed full-text fields (TypeFulltext), derived from the
|
||||
// overrides.
|
||||
var fulltextFields = sync.OnceValue(func() map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for field, opts := range (search.Resource{}).SearchFieldOverrides() {
|
||||
if opts.Type == mapping.TypeFulltext {
|
||||
out[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// ResolveField maps a KQL key to its canonical field name; unknown keys pass through.
|
||||
func ResolveField(name string) string {
|
||||
if v, ok := fieldIndex()[strings.ToLower(name)]; ok {
|
||||
return v
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// FieldIsCaseInsensitive reports whether a field's default search is case-insensitive.
|
||||
func FieldIsCaseInsensitive(field string) bool {
|
||||
_, ok := caseInsensitiveFields()[field]
|
||||
return ok
|
||||
}
|
||||
|
||||
// FieldIsPath reports whether a field is a hierarchical path field.
|
||||
func FieldIsPath(field string) bool {
|
||||
_, ok := pathFields()[field]
|
||||
return ok
|
||||
}
|
||||
|
||||
// FieldIsFulltext reports whether a field is an analyzed full-text field.
|
||||
func FieldIsFulltext(field string) bool {
|
||||
_, ok := fulltextFields()[field]
|
||||
return ok
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
@@ -19,8 +20,15 @@ import (
|
||||
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
|
||||
searchService "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/content"
|
||||
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
|
||||
)
|
||||
|
||||
// SchemaVersion is the shared schema version for both search backends. Bump it
|
||||
// on a breaking mapping change: each version gets its own index (OpenSearch name
|
||||
// suffix, bleve path suffix), so the service builds a fresh index instead of
|
||||
// colliding with the old one. No migration; reindex to populate.
|
||||
const SchemaVersion = 3
|
||||
|
||||
var scopeRegex = regexp.MustCompile(`scope:\s*([^" "\n\r]*)`)
|
||||
|
||||
// Engine is the interface to the search engine
|
||||
@@ -51,13 +59,34 @@ type BatchOperator interface {
|
||||
type Resource struct {
|
||||
content.Document
|
||||
|
||||
ID string
|
||||
RootID string
|
||||
Path string
|
||||
ParentID string
|
||||
Type uint64
|
||||
Deleted bool
|
||||
Hidden bool
|
||||
ID string `json:"ID"`
|
||||
RootID string `json:"RootID"`
|
||||
Path string `json:"Path"`
|
||||
ParentID string `json:"ParentID"`
|
||||
Type uint64 `json:"Type"`
|
||||
Deleted bool `json:"Deleted"`
|
||||
Hidden bool `json:"Hidden"`
|
||||
}
|
||||
|
||||
// resourceFieldOverrides is built once (it never changes) and reused on hot
|
||||
// paths instead of reallocating per call.
|
||||
var resourceFieldOverrides = sync.OnceValue(func() map[string]mapping.FieldOpts {
|
||||
True, False := true, false
|
||||
return map[string]mapping.FieldOpts{
|
||||
"Name": {CaseInsensitive: &True},
|
||||
"Path": {Type: mapping.TypePath},
|
||||
"Content": {Type: mapping.TypeFulltext},
|
||||
"Tags": {CaseInsensitive: &True, IncludeInAll: &False},
|
||||
"Favorites": {CaseInsensitive: &True, IncludeInAll: &False},
|
||||
"location": {Type: mapping.TypeGeopoint},
|
||||
}
|
||||
})
|
||||
|
||||
// SearchFieldOverrides returns the field options the mapping package needs to
|
||||
// build per-backend index mappings for a Resource (keys are json-tag names).
|
||||
// The map is shared and read-only; clone it before mutating.
|
||||
func (Resource) SearchFieldOverrides() map[string]mapping.FieldOpts {
|
||||
return resourceFieldOverrides()
|
||||
}
|
||||
|
||||
// ResolveReference makes sure the path is relative to the space root
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -635,10 +636,10 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) {
|
||||
|
||||
// determine if metadata needs to be stored in storage as well
|
||||
metadata := map[string]string{}
|
||||
addAudioMetadata(metadata, doc.Audio)
|
||||
addImageMetadata(metadata, doc.Image)
|
||||
addLocationMetadata(metadata, doc.Location)
|
||||
addPhotoMetadata(metadata, doc.Photo)
|
||||
facetToMetadata(metadata, doc.Audio, "libre.graph.audio.")
|
||||
facetToMetadata(metadata, doc.Image, "libre.graph.image.")
|
||||
facetToMetadata(metadata, doc.Location, "libre.graph.location.")
|
||||
facetToMetadata(metadata, doc.Photo, "libre.graph.photo.")
|
||||
if len(metadata) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -663,43 +664,25 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) {
|
||||
}
|
||||
}
|
||||
|
||||
func addAudioMetadata(metadata map[string]string, audio *libregraph.Audio) {
|
||||
if audio == nil {
|
||||
return
|
||||
// facetToMetadata flattens a libregraph facet (Audio / Image / Location / Photo
|
||||
// pointer) into the metadata map under the given prefix via the model's ToMap.
|
||||
// No-op when the facet is nil.
|
||||
func facetToMetadata[T libregraph.MappedNullable](metadata map[string]string, facet T, prefix string) {
|
||||
// Only nilable kinds can be nil; IsNil panics on a value type (some
|
||||
// libregraph models satisfy MappedNullable with a value receiver).
|
||||
switch v := reflect.ValueOf(facet); v.Kind() {
|
||||
case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Interface, reflect.Chan, reflect.Func:
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
}
|
||||
marshalToStringMap(audio, metadata, "libre.graph.audio.")
|
||||
}
|
||||
|
||||
func addImageMetadata(metadata map[string]string, image *libregraph.Image) {
|
||||
if image == nil {
|
||||
return
|
||||
}
|
||||
marshalToStringMap(image, metadata, "libre.graph.image.")
|
||||
}
|
||||
|
||||
func addLocationMetadata(metadata map[string]string, location *libregraph.GeoCoordinates) {
|
||||
if location == nil {
|
||||
return
|
||||
}
|
||||
marshalToStringMap(location, metadata, "libre.graph.location.")
|
||||
}
|
||||
|
||||
func addPhotoMetadata(metadata map[string]string, photo *libregraph.Photo) {
|
||||
if photo == nil {
|
||||
return
|
||||
}
|
||||
marshalToStringMap(photo, metadata, "libre.graph.photo.")
|
||||
}
|
||||
|
||||
func marshalToStringMap[T libregraph.MappedNullable](source T, target map[string]string, prefix string) {
|
||||
// ToMap never returns a non-nil error ...
|
||||
m, _ := source.ToMap()
|
||||
|
||||
// ToMap never returns a non-nil error.
|
||||
m, _ := facet.ToMap()
|
||||
for k, v := range m {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
target[prefix+k] = valueToString(v)
|
||||
metadata[prefix+k] = valueToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picture := m.Picture()
|
||||
picture := selectCoverArt(m.Pictures())
|
||||
if picture == nil {
|
||||
return nil, thumbnailerErrors.ErrNoImageFromAudioFile
|
||||
}
|
||||
@@ -102,6 +102,27 @@ func (i AudioDecoder) Convert(r io.Reader) (any, error) {
|
||||
return converter.Convert(bytes.NewReader(picture.Data))
|
||||
}
|
||||
|
||||
// frontCoverType is the ID3/APIC picture type byte for a front cover, shared by
|
||||
// the ID3v2, FLAC and OGG tag formats (see github.com/dhowden/tag pictureTypes).
|
||||
const frontCoverType byte = 0x03
|
||||
|
||||
// selectCoverArt deterministically picks the cover art from an audio file's
|
||||
// embedded pictures: the explicitly tagged front cover if present, otherwise
|
||||
// the first available picture. Many taggers store the cover as type "Other"
|
||||
// (0x00) instead of "Cover (front)" (0x03), so the fallback is the common case.
|
||||
// It returns nil when there are no pictures.
|
||||
func selectCoverArt(pictures []tag.Picture) *tag.Picture {
|
||||
if len(pictures) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range pictures {
|
||||
if pictures[i].RawType == frontCoverType {
|
||||
return &pictures[i]
|
||||
}
|
||||
}
|
||||
return &pictures[0]
|
||||
}
|
||||
|
||||
// TxtToImageConverter is a converter for the text file
|
||||
type TxtToImageConverter struct {
|
||||
fontLoader *FontLoader
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/opentype"
|
||||
|
||||
@@ -127,6 +128,28 @@ var _ = Describe("ImageDecoder", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("selectCoverArt", func() {
|
||||
front := tag.Picture{RawType: frontCoverType, MIMEType: "image/jpeg", Data: []byte("front")}
|
||||
back := tag.Picture{RawType: 0x04, MIMEType: "image/jpeg", Data: []byte("back")}
|
||||
other := tag.Picture{RawType: 0x00, MIMEType: "image/png", Data: []byte("other")}
|
||||
|
||||
It("returns nil when there are no pictures", func() {
|
||||
Expect(selectCoverArt(nil)).To(BeNil())
|
||||
Expect(selectCoverArt([]tag.Picture{})).To(BeNil())
|
||||
})
|
||||
It("prefers the front cover regardless of order", func() {
|
||||
got := selectCoverArt([]tag.Picture{back, other, front})
|
||||
Expect(got).ToNot(BeNil())
|
||||
Expect(got.RawType).To(Equal(frontCoverType))
|
||||
Expect(got.Data).To(Equal([]byte("front")))
|
||||
})
|
||||
It("falls back to the first picture when none is a front cover", func() {
|
||||
got := selectCoverArt([]tag.Picture{other, back})
|
||||
Expect(got).ToNot(BeNil())
|
||||
Expect(got.Data).To(Equal([]byte("other")))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("should decode text", func() {
|
||||
var decoder TxtToImageConverter
|
||||
BeforeEach(func() {
|
||||
|
||||
@@ -5,4 +5,6 @@ services:
|
||||
- tika
|
||||
command: tika:9998
|
||||
tika:
|
||||
image: apache/tika:3.2.3.0-full
|
||||
# latest snapshot, pinned by digest (the SNAPSHOT tag is rebuilt daily);
|
||||
# needed for the audio/GPS metadata keys added in the tika 4.0.0 dev line.
|
||||
image: apache/tika:4.0.0-SNAPSHOT-full@sha256:64a022ebae0830e90067d619b6b1b1d5640d5bfd635b2173ae51ed8de967078f
|
||||
+4
@@ -96,6 +96,10 @@ func (m metadataDSF) Picture() *Picture {
|
||||
return m.id3.Picture()
|
||||
}
|
||||
|
||||
func (m metadataDSF) Pictures() []Picture {
|
||||
return m.id3.Pictures()
|
||||
}
|
||||
|
||||
func (m metadataDSF) Lyrics() string {
|
||||
return m.id3.Lyrics()
|
||||
}
|
||||
|
||||
+1
@@ -140,5 +140,6 @@ func (m metadataID3v1) AlbumArtist() string { return "" }
|
||||
func (m metadataID3v1) Composer() string { return "" }
|
||||
func (metadataID3v1) Disc() (int, int) { return 0, 0 }
|
||||
func (m metadataID3v1) Picture() *Picture { return nil }
|
||||
func (m metadataID3v1) Pictures() []Picture { return nil }
|
||||
func (m metadataID3v1) Lyrics() string { return "" }
|
||||
func (m metadataID3v1) Comment() string { return m["comment"].(string) }
|
||||
+3
@@ -546,6 +546,7 @@ type Picture struct {
|
||||
Ext string // Extension of the picture file.
|
||||
MIMEType string // MIMEType of the picture.
|
||||
Type string // Type of the picture (see pictureTypes).
|
||||
RawType byte // Raw picture type byte (see pictureTypes); 0x03 is the front cover.
|
||||
Description string // Description.
|
||||
Data []byte // Raw picture data.
|
||||
}
|
||||
@@ -596,6 +597,7 @@ func readPICFrame(b []byte) (*Picture, error) {
|
||||
Ext: ext,
|
||||
MIMEType: mimeType,
|
||||
Type: pictureTypes[picType],
|
||||
RawType: picType,
|
||||
Description: desc,
|
||||
Data: descDataSplit[1],
|
||||
}, nil
|
||||
@@ -649,6 +651,7 @@ func readAPICFrame(b []byte) (*Picture, error) {
|
||||
Ext: ext,
|
||||
MIMEType: mimeType,
|
||||
Type: pictureTypes[picType],
|
||||
RawType: picType,
|
||||
Description: desc,
|
||||
Data: descDataSplit[1],
|
||||
}, nil
|
||||
|
||||
+25
@@ -150,3 +150,28 @@ func (m metadataID3v2) Picture() *Picture {
|
||||
}
|
||||
return v.(*Picture)
|
||||
}
|
||||
|
||||
// Pictures returns all attached pictures. Duplicate picture frames are stored
|
||||
// under suffixed keys ("APIC", "APIC_0", "APIC_1", ...) by readFrames, so we
|
||||
// walk that sequence to return them in file order.
|
||||
func (m metadataID3v2) Pictures() []Picture {
|
||||
base := frames.Name("picture", m.Format())
|
||||
if base == "" {
|
||||
return nil
|
||||
}
|
||||
var pics []Picture
|
||||
for i := -1; ; i++ {
|
||||
key := base
|
||||
if i >= 0 {
|
||||
key = base + "_" + strconv.Itoa(i)
|
||||
}
|
||||
v, ok := m.frames[key]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if p, ok := v.(*Picture); ok {
|
||||
pics = append(pics, *p)
|
||||
}
|
||||
}
|
||||
return pics
|
||||
}
|
||||
+10
@@ -377,3 +377,13 @@ func (m metadataMP4) Picture() *Picture {
|
||||
p, _ := v.(*Picture)
|
||||
return p
|
||||
}
|
||||
|
||||
// Pictures returns the attached cover art. The MP4 "covr" atom carries no
|
||||
// picture type, so at most a single untyped picture is returned.
|
||||
func (m metadataMP4) Pictures() []Picture {
|
||||
p := m.Picture()
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return []Picture{*p}
|
||||
}
|
||||
+3
@@ -135,6 +135,9 @@ type Metadata interface {
|
||||
// Picture returns a picture, or nil if not available.
|
||||
Picture() *Picture
|
||||
|
||||
// Pictures returns all attached pictures, or nil if none are available.
|
||||
Pictures() []Picture
|
||||
|
||||
// Lyrics returns the lyrics, or an empty string if unavailable.
|
||||
Lyrics() string
|
||||
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user