feat(search): derive a case-insensitive field-name index from the resource struct

mapping.FieldNameIndex walks the struct and maps a lowercased field path to the
real field name, including nested facet sub-fields. Backend-neutral.
This commit is contained in:
Dominik Schmidt
2026-07-29 17:39:23 +02:00
parent 991d36d5ad
commit 1a4daf38bf
2 changed files with 115 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
package mapping
import (
"reflect"
"strings"
)
// FieldNameIndex maps a lowercased field path to the real field name for every
// field of t, recursing into nested facets (photo.cameraMake, ...). Names come
// from json tags, so it is backend-neutral; the query layer resolves KQL keys
// case-insensitively against it.
func FieldNameIndex(t reflect.Type, overrides map[string]FieldOpts) map[string]string {
out := map[string]string{}
var walk func(t reflect.Type, prefix string)
walk = func(t reflect.Type, prefix string) {
_ = walkFields(t, func(fi fieldInfo) error {
name := fi.Name
if prefix != "" {
name = prefix + "." + fi.Name
}
out[strings.ToLower(name)] = name
fieldType := overrides[name].Type
if fieldType == "" {
fieldType = inferType(fi.GoField.Type)
}
// recurse into nested facets; time.Time is a struct too but a leaf.
if sub := structType(fi.GoField.Type); sub != nil && fieldType != TypeDatetime {
walk(sub, name)
}
return nil
})
}
walk(t, "")
return out
}

View File

@@ -0,0 +1,79 @@
package mapping_test
import (
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/services/search/pkg/mapping"
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
)
func resourceFieldIndex(testing.TB) 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
}
func TestFieldNameIndex_TopLevelCaseInsensitive(t *testing.T) {
idx := resourceFieldIndex(t)
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",
} {
require.Equalf(t, want, resolve(idx, in), "resolve(%q)", in)
}
}
// Facet sub-fields are lowerCamelCase in the index (from libregraph json tags);
// the derived index resolves them case-insensitively.
func TestFieldNameIndex_FacetsCaseInsensitive(t *testing.T) {
idx := resourceFieldIndex(t)
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",
} {
require.Equalf(t, want, resolve(idx, in), "resolve(%q)", in)
}
}
func TestFieldNameIndex_UnknownPassesThrough(t *testing.T) {
idx := resourceFieldIndex(t)
require.Equal(t, "nope.field", resolve(idx, "nope.field"))
require.Equal(t, "custom", resolve(idx, "custom"))
}
// All top-level fields are covered from one derived source, so both backends
// resolve them the same way.
func TestFieldNameIndex_CoversAllTopLevelFields(t *testing.T) {
idx := resourceFieldIndex(t)
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",
} {
require.Equalf(t, want, resolve(idx, in), "derived should cover %q", in)
}
}