Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a12e53f
refactor(search): add json tags to Document and Resource fields
dschmidt Apr 22, 2026
f4b7a35
refactor(search): add mapping package core (opts, infer, validate)
dschmidt Apr 22, 2026
4d1ebb5
refactor(search): add BleveBuildMapping to the mapping package
dschmidt Apr 22, 2026
4053011
refactor(search): add OpenSearchBuildMapping to the mapping package
dschmidt Apr 22, 2026
664127d
refactor(search): add PrepareForIndex to the mapping package
dschmidt Apr 22, 2026
26b652e
refactor(search): add Deserialize[T] to the mapping package
dschmidt Apr 22, 2026
da18ce1
refactor(search): build the bleve document mapping via reflection
dschmidt Apr 22, 2026
fec9931
refactor(search): route bleve batch Upsert through mapping.PrepareFor…
dschmidt Apr 22, 2026
3cfbfcb
refactor(search): replace hand-rolled bleve hit deserializers
dschmidt Apr 22, 2026
4244c4e
refactor(search): generate the OpenSearch V2 index mapping at runtime
dschmidt Apr 22, 2026
b295003
refactor(search): collapse add*Metadata helpers into a generic wrapper
dschmidt Apr 22, 2026
d622fec
refactor(search): add DeserializeStringMap to the mapping package
dschmidt Apr 22, 2026
8df64e3
refactor(graph): route CS3 facet parsing through mapping.DeserializeS…
dschmidt Apr 22, 2026
167b169
refactor(search): collapse OpenSearch facet conversion via copyFacet …
dschmidt Apr 22, 2026
0899f88
feat(search): index Location as a geopoint on both backends
dschmidt Apr 22, 2026
7463359
refactor(search): geopoint as sibling field on both backends
dschmidt Apr 22, 2026
0c37aa2
refactor(search): PrepareForIndex via conversions.To
dschmidt Apr 22, 2026
4d5b1ae
refactor(search): drop dead overrides param from Deserialize, make it…
dschmidt Apr 22, 2026
10edb7c
refactor(search): drop the audio/ MimeType guard on the read path
dschmidt Apr 22, 2026
fbfdca1
refactor(search): address small review findings
dschmidt Apr 22, 2026
e948da6
refactor(search): restore single-allocation setSlice
dschmidt Apr 22, 2026
7117af2
refactor(search): derive lowercaseFields from SearchFieldOverrides
dschmidt Apr 23, 2026
11d32a5
refactor(search): per-field fail-soft for CS3 metadata, drop dead err…
dschmidt May 12, 2026
4bc5b7a
refactor(search): inline DeserializeAt facet calls into Match struct …
dschmidt May 12, 2026
6133be9
refactor(search): drop the unused resource_v1 OpenSearch mapping
dschmidt May 12, 2026
9e1e56b
fix(search): explain how to recover from OpenSearch index mapping drift
dschmidt May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 13 additions & 120 deletions services/graph/pkg/service/v0/driveitems.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"net/http"
"net/url"
"path"
"reflect"
"strconv"
"strings"
"time"
Expand All @@ -30,6 +29,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.
Expand Down Expand Up @@ -471,130 +471,23 @@ func cs3ResourceToDriveItem(logger *log.Logger, res *storageprovider.ResourceInf
driveItem.Folder = &libregraph.Folder{}
}

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 {
setFacet(&driveItem.Audio, metadata, "libre.graph.audio.")
setFacet(&driveItem.Image, metadata, "libre.graph.image.")
setFacet(&driveItem.Location, metadata, "libre.graph.location.")
setFacet(&driveItem.Photo, metadata, "libre.graph.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
// setFacet decodes a libre.graph.<facet>.* slice of CS3 ArbitraryMetadata
// into *dst. DeserializeStringMap is fail-soft per field: malformed
// individual values are silently zeroed, the rest of the facet still
// populates. *dst stays nil only when no fields under prefix were present
// at all (which is what DeserializeStringMap returns).
func setFacet[T any](dst **T, metadata map[string]string, prefix string) {
*dst = mapping.DeserializeStringMap[T](metadata, prefix)
}

func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
Expand Down
9 changes: 5 additions & 4 deletions services/search/pkg/bleve/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"

"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"

searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
Expand Down Expand Up @@ -136,10 +137,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: mapping.DeserializeAt[searchMessage.Audio](hit.Fields, "audio"),
Image: mapping.DeserializeAt[searchMessage.Image](hit.Fields, "image"),
Location: mapping.DeserializeAt[searchMessage.GeoCoordinates](hit.Fields, "location"),
Photo: mapping.DeserializeAt[searchMessage.Photo](hit.Fields, "photo"),
},
}

Expand Down
20 changes: 16 additions & 4 deletions services/search/pkg/bleve/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
137 changes: 7 additions & 130 deletions services/search/pkg/bleve/bleve.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -75,131 +70,13 @@ 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]
}

// 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 {
Expand Down
Loading