mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-08-01 10:20:52 -04:00
test(search): convert mapping package tests to ginkgo
New package, so use the repo's standard test framework.
This commit is contained in:
@@ -2,8 +2,10 @@ package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type bleveDoc struct {
|
||||
@@ -21,140 +23,93 @@ type nested struct {
|
||||
Year int `json:"year"`
|
||||
}
|
||||
|
||||
// bleve wildcard falls back to keyword-ish text (bleve has no wildcard type).
|
||||
func TestBleveWildcardFallback(t *testing.T) {
|
||||
type doc struct {
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"mime": {Type: TypeWildcard}})
|
||||
if err != nil {
|
||||
t.Fatalf("BleveBuildMapping: %v", err)
|
||||
}
|
||||
fms := dm.Properties["mime"].Fields
|
||||
if len(fms) != 1 || fms[0].Type != "text" {
|
||||
t.Fatalf("wildcard should map to text, got %+v", fms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBleveBuildMappingInferredTypes(t *testing.T) {
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BleveBuildMapping: %v", err)
|
||||
}
|
||||
cases := map[string]string{
|
||||
"Name": "text",
|
||||
"Content": "text",
|
||||
"Tags": "text",
|
||||
"Size": "number",
|
||||
"Deleted": "boolean",
|
||||
"CreatedAt": "datetime",
|
||||
}
|
||||
for field, wantType := range cases {
|
||||
prop := dm.Properties[field]
|
||||
if prop == nil {
|
||||
t.Errorf("missing property %q", field)
|
||||
continue
|
||||
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"`
|
||||
}
|
||||
if len(prop.Fields) == 0 {
|
||||
t.Errorf("%q: no field mappings", field)
|
||||
continue
|
||||
}
|
||||
if got := prop.Fields[0].Type; got != wantType {
|
||||
t.Errorf("%q: got type %q, want %q", field, got, wantType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBleveBuildMappingNestedIsSubDocument(t *testing.T) {
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BleveBuildMapping: %v", err)
|
||||
}
|
||||
sub := dm.Properties["nested"]
|
||||
if sub == nil {
|
||||
t.Fatal("missing nested sub-document")
|
||||
}
|
||||
if sub.Properties["artist"] == nil || sub.Properties["year"] == nil {
|
||||
t.Fatalf("nested fields missing: %#v", sub.Properties)
|
||||
}
|
||||
if got := sub.Properties["artist"].Fields[0].Type; got != "text" {
|
||||
t.Errorf("nested.artist: type %q, want text", got)
|
||||
}
|
||||
if got := sub.Properties["year"].Fields[0].Type; got != "number" {
|
||||
t.Errorf("nested.year: type %q, want number", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBleveBuildMappingOverrides(t *testing.T) {
|
||||
includeInAllFalse := false
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{
|
||||
"Name": {Analyzer: "lowercaseKeyword"},
|
||||
"Content": {Type: TypeFulltext},
|
||||
"Tags": {Analyzer: "lowercaseKeyword", IncludeInAll: &includeInAllFalse},
|
||||
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")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BleveBuildMapping: %v", err)
|
||||
}
|
||||
nameField := dm.Properties["Name"].Fields[0]
|
||||
if nameField.Analyzer != "lowercaseKeyword" {
|
||||
t.Errorf("Name analyzer: %q, want lowercaseKeyword", nameField.Analyzer)
|
||||
}
|
||||
if !nameField.IncludeInAll {
|
||||
t.Errorf("Name IncludeInAll should stay default-true when not overridden")
|
||||
}
|
||||
contentField := dm.Properties["Content"].Fields[0]
|
||||
if contentField.Analyzer != "fulltext" {
|
||||
t.Errorf("Content analyzer: %q, want fulltext", contentField.Analyzer)
|
||||
}
|
||||
if contentField.IncludeInAll {
|
||||
t.Errorf("Content IncludeInAll should default to false for fulltext type")
|
||||
}
|
||||
tagsField := dm.Properties["Tags"].Fields[0]
|
||||
if tagsField.IncludeInAll {
|
||||
t.Errorf("Tags IncludeInAll should honor the explicit false override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBleveBuildMappingGeopoint(t *testing.T) {
|
||||
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},
|
||||
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")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BleveBuildMapping: %v", err)
|
||||
}
|
||||
// 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"]
|
||||
if loc == nil {
|
||||
t.Fatalf("location sub-document missing: %#v", dm.Properties)
|
||||
}
|
||||
if len(loc.Fields) != 0 {
|
||||
t.Errorf("location should not carry field mappings directly, got %#v", loc.Fields)
|
||||
}
|
||||
for _, sub := range []string{"longitude", "latitude", "altitude"} {
|
||||
prop, ok := loc.Properties[sub]
|
||||
if !ok {
|
||||
t.Errorf("missing sub-field %q under location (properties: %v)", sub, loc.Properties)
|
||||
continue
|
||||
|
||||
It("applies field overrides", func() {
|
||||
includeInAllFalse := false
|
||||
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{
|
||||
"Name": {Analyzer: "lowercaseKeyword"},
|
||||
"Content": {Type: TypeFulltext},
|
||||
"Tags": {Analyzer: "lowercaseKeyword", IncludeInAll: &includeInAllFalse},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
nameField := dm.Properties["Name"].Fields[0]
|
||||
Expect(nameField.Analyzer).To(Equal("lowercaseKeyword"), "Name analyzer")
|
||||
Expect(nameField.IncludeInAll).To(BeTrue(), "Name IncludeInAll should stay default-true when not overridden")
|
||||
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")
|
||||
tagsField := dm.Properties["Tags"].Fields[0]
|
||||
Expect(tagsField.IncludeInAll).To(BeFalse(), "Tags IncludeInAll should honor the explicit false override")
|
||||
})
|
||||
|
||||
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"`
|
||||
}
|
||||
if len(prop.Fields) == 0 || prop.Fields[0].Type != "number" {
|
||||
t.Errorf("location.%s Fields: %#v, want [number]", sub, prop.Fields)
|
||||
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]
|
||||
if sibling == nil {
|
||||
t.Fatalf("location%s missing: %#v", GeopointSuffix, dm.Properties)
|
||||
}
|
||||
if len(sibling.Fields) == 0 || sibling.Fields[0].Type != "geopoint" {
|
||||
t.Errorf("location%s Fields: %#v, want [geopoint]", GeopointSuffix, sibling.Fields)
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,10 @@ package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -17,106 +18,81 @@ type stringFacet struct {
|
||||
Taken *time.Time `json:"takenDateTime,omitempty"`
|
||||
}
|
||||
|
||||
func TestSetValueFromStringUnsupportedKind(t *testing.T) {
|
||||
v := reflect.New(reflect.TypeFor[[]int]()).Elem() // settable slice
|
||||
if err := setValueFromString(v, "x"); err == nil {
|
||||
t.Error("expected error for unsupported target kind (slice)")
|
||||
}
|
||||
}
|
||||
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)")
|
||||
})
|
||||
|
||||
func TestDeserializeStringsAtBasicTypes(t *testing.T) {
|
||||
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.")
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil *stringFacet")
|
||||
}
|
||||
if r.Artist == nil || *r.Artist != "Queen" {
|
||||
t.Errorf("Artist: %#v", r.Artist)
|
||||
}
|
||||
if r.Year == nil || *r.Year != 1975 {
|
||||
t.Errorf("Year: %#v", r.Year)
|
||||
}
|
||||
if r.Duration == nil || *r.Duration != 354000 {
|
||||
t.Errorf("Duration: %#v", r.Duration)
|
||||
}
|
||||
if r.Rating == nil || *r.Rating != 4.9 {
|
||||
t.Errorf("Rating: %#v", r.Rating)
|
||||
}
|
||||
if r.Explicit == nil || !*r.Explicit {
|
||||
t.Errorf("Explicit: %#v", r.Explicit)
|
||||
}
|
||||
if r.Taken == nil || !r.Taken.Equal(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)) {
|
||||
t.Errorf("Taken: %#v", r.Taken)
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
func TestDeserializeStringsAtReturnsNilWhenEmpty(t *testing.T) {
|
||||
r := DeserializeStringsAt[stringFacet](map[string]string{
|
||||
"libre.graph.image.width": "1200",
|
||||
}, "libre.graph.audio.")
|
||||
if r != nil {
|
||||
t.Fatalf("expected nil, got %#v", r)
|
||||
}
|
||||
}
|
||||
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())
|
||||
})
|
||||
|
||||
func TestDeserializeStringsAtTimestamppb(t *testing.T) {
|
||||
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.")
|
||||
if r == nil || r.Taken == nil {
|
||||
t.Fatalf("Taken missing: %#v", r)
|
||||
}
|
||||
want := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)
|
||||
if !r.Taken.AsTime().Equal(want) {
|
||||
t.Errorf("Taken: got %v, want %v", r.Taken.AsTime(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeserializeStringsAtIsFailSoft(t *testing.T) {
|
||||
// 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.")
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil *stringFacet despite bad fields")
|
||||
}
|
||||
if r.Artist == nil || *r.Artist != "Iron Maiden" {
|
||||
t.Errorf("Artist should still be populated, got %#v", r.Artist)
|
||||
}
|
||||
if r.Duration == nil || *r.Duration != 354000 {
|
||||
t.Errorf("Duration should still be populated, got %#v", r.Duration)
|
||||
}
|
||||
if r.Rating == nil || *r.Rating != 4.9 {
|
||||
t.Errorf("Rating should still be populated, got %#v", r.Rating)
|
||||
}
|
||||
if r.Year != nil {
|
||||
t.Errorf("Year should stay nil for bad int, got %#v", r.Year)
|
||||
}
|
||||
if r.Explicit != nil {
|
||||
t.Errorf("Explicit should stay nil for bad bool, got %#v", r.Explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeserializeStringsAtPanicsOnNonStruct(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected panic for non-struct T")
|
||||
It("parses into a timestamppb.Timestamp", func() {
|
||||
type photoFacet struct {
|
||||
Taken *timestamppb.Timestamp `json:"takenDateTime,omitempty"`
|
||||
}
|
||||
}()
|
||||
DeserializeStringsAt[int](nil, "")
|
||||
}
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -31,125 +32,87 @@ type embedded struct {
|
||||
Photo *photo `json:"photo,omitempty"`
|
||||
}
|
||||
|
||||
func TestDeserializeAtNonStructPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("expected panic for non-struct type")
|
||||
}
|
||||
}()
|
||||
_ = DeserializeAt[int](map[string]any{}, "")
|
||||
}
|
||||
|
||||
func TestDeserializeLeafFields(t *testing.T) {
|
||||
r := Deserialize[Leaf](map[string]any{
|
||||
"Name": "n",
|
||||
"Size": float64(42),
|
||||
"Deleted": true,
|
||||
var _ = Describe("Deserialize", func() {
|
||||
It("panics for a non-struct type at a prefix", func() {
|
||||
Expect(func() {
|
||||
_ = DeserializeAt[int](map[string]any{}, "")
|
||||
}).To(Panic())
|
||||
})
|
||||
if r.Name != "n" || r.Size != 42 || !r.Deleted {
|
||||
t.Fatalf("got %#v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeserializeScalarToSlice(t *testing.T) {
|
||||
r := Deserialize[Leaf](map[string]any{
|
||||
"Tags": "single",
|
||||
"Favorites": []any{"a", "b"},
|
||||
It("panics for a non-struct T", func() {
|
||||
Expect(func() {
|
||||
Deserialize[int](nil)
|
||||
}).To(Panic())
|
||||
})
|
||||
if len(r.Tags) != 1 || r.Tags[0] != "single" {
|
||||
t.Errorf("Tags: %#v", r.Tags)
|
||||
}
|
||||
if len(r.Favorites) != 2 || r.Favorites[0] != "a" || r.Favorites[1] != "b" {
|
||||
t.Errorf("Favorites: %#v", r.Favorites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeserializeTimestamp(t *testing.T) {
|
||||
r := Deserialize[embedded](map[string]any{
|
||||
"photo.takenDateTime": "2024-01-02T03:04:05Z",
|
||||
"photo.mtime": "2024-05-06T07:08:09Z",
|
||||
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())
|
||||
})
|
||||
if r.Photo == nil {
|
||||
t.Fatal("Photo is nil")
|
||||
}
|
||||
if r.Photo.Taken == nil {
|
||||
t.Fatal("Taken is nil")
|
||||
}
|
||||
expected := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if !r.Photo.Taken.AsTime().Equal(expected) {
|
||||
t.Errorf("Taken: got %v, want %v", r.Photo.Taken.AsTime(), expected)
|
||||
}
|
||||
if r.Photo.Mtime == nil {
|
||||
t.Fatal("Mtime is nil")
|
||||
}
|
||||
if !r.Photo.Mtime.Equal(time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)) {
|
||||
t.Errorf("Mtime: %v", r.Photo.Mtime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeserializeIsFailSoft(t *testing.T) {
|
||||
// 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",
|
||||
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"}))
|
||||
})
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil *embedded even with partial corruption")
|
||||
}
|
||||
if r.Name != "n" {
|
||||
t.Errorf("Name: %q", r.Name)
|
||||
}
|
||||
if r.Size != 0 {
|
||||
t.Errorf("Size should stay zero on mismatch, got %d", r.Size)
|
||||
}
|
||||
if !r.Deleted {
|
||||
t.Errorf("Deleted should still be true")
|
||||
}
|
||||
if r.Photo == nil {
|
||||
t.Fatal("Photo should be populated because Mtime parsed ok")
|
||||
}
|
||||
if r.Photo.Taken != nil {
|
||||
t.Errorf("Taken should stay nil for unparseable time, got %v", r.Photo.Taken)
|
||||
}
|
||||
if r.Photo.Mtime == nil {
|
||||
t.Error("Mtime should be parsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeserializePanicsOnNonStruct(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected panic for non-struct T")
|
||||
}
|
||||
}()
|
||||
Deserialize[int](nil)
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
func TestDeserializeAtReturnsNilWhenNothingMatches(t *testing.T) {
|
||||
r := DeserializeAt[audio](map[string]any{"Name": "n"}, "audio")
|
||||
if r != nil {
|
||||
t.Fatalf("expected nil, got %#v", r)
|
||||
}
|
||||
}
|
||||
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")
|
||||
})
|
||||
|
||||
func TestDeserializeAtReturnsValueWhenPrefixMatches(t *testing.T) {
|
||||
r := DeserializeAt[audio](map[string]any{
|
||||
"audio.artist": "A",
|
||||
"audio.year": float64(2024), // setValue: pointer + numeric convert
|
||||
}, "audio")
|
||||
if r == nil {
|
||||
t.Fatal("expected non-nil *audio")
|
||||
}
|
||||
if r.Artist == nil || *r.Artist != "A" {
|
||||
t.Errorf("Artist: %#v", r.Artist)
|
||||
}
|
||||
if r.Year == nil || *r.Year != 2024 {
|
||||
t.Errorf("Year: %#v", r.Year)
|
||||
}
|
||||
}
|
||||
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)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,9 @@ package mapping
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// The walker is shared by both deserializers, so its structural behavior
|
||||
@@ -45,83 +47,60 @@ func fsSet(v reflect.Value, raw string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFillStruct(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
t.Run("flattens embedded, recurses nested", func(t *testing.T) {
|
||||
It("flattens embedded and recurses nested", func() {
|
||||
root, touched := fill(map[string]string{
|
||||
"leaf": "L",
|
||||
"ev": "EV",
|
||||
"ep": "EP",
|
||||
"nested.n": "N",
|
||||
}, "")
|
||||
if !touched {
|
||||
t.Fatal("expected touched")
|
||||
}
|
||||
if root.Leaf != "L" {
|
||||
t.Errorf("Leaf: %q", root.Leaf)
|
||||
}
|
||||
if root.EV != "EV" {
|
||||
t.Errorf("embedded value not promoted: %q", root.EV)
|
||||
}
|
||||
if root.FsEmbPtr == nil || root.EP != "EP" {
|
||||
t.Errorf("embedded pointer not allocated: %+v", root.FsEmbPtr)
|
||||
}
|
||||
if root.Nested == nil || root.Nested.N != "N" {
|
||||
t.Errorf("nested pointer not populated: %+v", root.Nested)
|
||||
}
|
||||
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"))
|
||||
})
|
||||
|
||||
t.Run("nothing matches: touched false, pointers stay nil", func(t *testing.T) {
|
||||
It("leaves touched false and pointers nil when nothing matches", func() {
|
||||
root, touched := fill(map[string]string{"other": "x"}, "")
|
||||
if touched {
|
||||
t.Fatal("expected untouched")
|
||||
}
|
||||
if root.Nested != nil {
|
||||
t.Errorf("Nested should stay nil: %+v", root.Nested)
|
||||
}
|
||||
if root.FsEmbPtr != nil {
|
||||
t.Errorf("embedded pointer should stay nil: %+v", root.FsEmbPtr)
|
||||
}
|
||||
Expect(touched).To(BeFalse())
|
||||
Expect(root.Nested).To(BeNil(), "Nested should stay nil")
|
||||
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil")
|
||||
})
|
||||
|
||||
t.Run("prefix arg is joined with the field name", func(t *testing.T) {
|
||||
It("joins the prefix arg with the field name", func() {
|
||||
root, touched := fill(map[string]string{
|
||||
"pre.leaf": "L",
|
||||
"pre.nested.n": "N",
|
||||
}, "pre")
|
||||
if !touched || root.Leaf != "L" || root.Nested == nil || root.Nested.N != "N" {
|
||||
t.Fatalf("prefix not joined: leaf=%q nested=%+v", root.Leaf, root.Nested)
|
||||
}
|
||||
Expect(touched).To(BeTrue())
|
||||
Expect(root.Leaf).To(Equal("L"))
|
||||
Expect(root.Nested).ToNot(BeNil())
|
||||
Expect(root.Nested.N).To(Equal("N"))
|
||||
})
|
||||
|
||||
t.Run("fail-soft: errored leaf stays zero, walk continues", func(t *testing.T) {
|
||||
It("is fail-soft: errored leaf stays zero, walk continues", func() {
|
||||
root, touched := fill(map[string]string{
|
||||
"leaf": "BAD",
|
||||
"ev": "EV",
|
||||
}, "")
|
||||
if !touched {
|
||||
t.Fatal("expected touched because ev was set")
|
||||
}
|
||||
if root.Leaf != "" {
|
||||
t.Errorf("errored leaf should stay zero, got %q", root.Leaf)
|
||||
}
|
||||
if root.EV != "EV" {
|
||||
t.Errorf("walk should continue past the error: %q", root.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")
|
||||
})
|
||||
|
||||
t.Run("embedded pointer dropped when its only field errors", func(t *testing.T) {
|
||||
It("drops the embedded pointer when its only field errors", func() {
|
||||
root, touched := fill(map[string]string{"ep": "BAD"}, "")
|
||||
if touched {
|
||||
t.Fatal("expected untouched")
|
||||
}
|
||||
if root.FsEmbPtr != nil {
|
||||
t.Errorf("embedded pointer should stay nil on error, got %+v", root.FsEmbPtr)
|
||||
}
|
||||
Expect(touched).To(BeFalse())
|
||||
Expect(root.FsEmbPtr).To(BeNil(), "embedded pointer should stay nil on error")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,159 +2,142 @@ package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// build-mapping error paths: an override that doesn't fit the Go field.
|
||||
func TestBuildMappingErrors(t *testing.T) {
|
||||
type doc struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
// Geopoint on a non-struct field must error on both backends.
|
||||
if _, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}}); err == nil {
|
||||
t.Error("bleve: expected error for geopoint on string field")
|
||||
}
|
||||
if _, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"name": {Type: TypeGeopoint}}); err == nil {
|
||||
t.Error("opensearch: expected error for geopoint on string field")
|
||||
}
|
||||
}
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
func TestAddGeopointSiblingMissingIntermediate(t *testing.T) {
|
||||
m := map[string]any{"journey": "not-a-map"}
|
||||
addGeopointSibling(m, "journey.start") // must bail, not panic
|
||||
if _, ok := m["journey.start"+GeopointSuffix]; ok {
|
||||
t.Error("no sibling should be written when the path can't be resolved")
|
||||
}
|
||||
}
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
func TestPrepareForIndexAddsGeopointSibling(t *testing.T) {
|
||||
type geoDoc struct {
|
||||
Location *struct {
|
||||
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"`
|
||||
} `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}}
|
||||
}{Longitude: &lon, Latitude: &lat, Altitude: &alt}}
|
||||
|
||||
m, err := PrepareForIndex(doc, map[string]FieldOpts{
|
||||
"location": {Type: TypeGeopoint},
|
||||
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))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForIndex: %v", err)
|
||||
}
|
||||
|
||||
// Original location object stays untouched (full libregraph shape).
|
||||
orig, ok := m["location"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected location object preserved, got %T", m["location"])
|
||||
}
|
||||
if orig["longitude"] != lon || orig["latitude"] != lat || orig["altitude"] != alt {
|
||||
t.Errorf("location object: %#v", orig)
|
||||
}
|
||||
|
||||
// Sibling location_geopoint has {lat, lon} for the geo indices.
|
||||
gp, ok := m["location"+GeopointSuffix].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected location_geopoint sibling, got %T", m["location"+GeopointSuffix])
|
||||
}
|
||||
if gp["lat"] != lat || gp["lon"] != lon {
|
||||
t.Errorf("sibling: %#v", gp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareForIndexSkipsIncompleteGeopoint(t *testing.T) {
|
||||
type geoDoc struct {
|
||||
Location *struct {
|
||||
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"`
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
alt := 100.0
|
||||
doc := geoDoc{Location: &struct {
|
||||
Altitude *float64 `json:"altitude,omitempty"`
|
||||
}{Altitude: &alt}}
|
||||
}{Altitude: &alt}}
|
||||
|
||||
m, err := PrepareForIndex(doc, map[string]FieldOpts{
|
||||
"location": {Type: TypeGeopoint},
|
||||
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")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForIndex: %v", err)
|
||||
}
|
||||
// Original stays (altitude alone is still useful metadata).
|
||||
if _, ok := m["location"]; !ok {
|
||||
t.Error("location should still be present when only altitude is set")
|
||||
}
|
||||
// No sibling without both lon and lat.
|
||||
if _, ok := m["location"+GeopointSuffix]; ok {
|
||||
t.Errorf("no sibling expected, got %#v", m["location"+GeopointSuffix])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareForIndexWithoutOverrideNoSibling(t *testing.T) {
|
||||
type geoDoc struct {
|
||||
Location *struct {
|
||||
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"`
|
||||
} `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}}
|
||||
}{Longitude: &lon, Latitude: &lat}}
|
||||
|
||||
m, err := PrepareForIndex(doc, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForIndex: %v", err)
|
||||
}
|
||||
if _, ok := m["location"+GeopointSuffix]; ok {
|
||||
t.Errorf("no sibling expected without override, got %#v", m["location"+GeopointSuffix])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareForIndexHandlesNestedGeopoint(t *testing.T) {
|
||||
// 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},
|
||||
m, err := PrepareForIndex(doc, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(m).ToNot(HaveKey("location"+GeopointSuffix), "no sibling expected without override")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForIndex: %v", err)
|
||||
}
|
||||
j, ok := m["journey"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("journey not an object: %T", m["journey"])
|
||||
}
|
||||
startGp, ok := j["start"+GeopointSuffix].(map[string]any)
|
||||
if !ok || startGp["lat"] != slat || startGp["lon"] != slon {
|
||||
t.Errorf("journey.start sibling: %#v", j["start"+GeopointSuffix])
|
||||
}
|
||||
endGp, ok := j["end"+GeopointSuffix].(map[string]any)
|
||||
if !ok || endGp["lat"] != elat || endGp["lon"] != elon {
|
||||
t.Errorf("journey.end sibling: %#v", j["end"+GeopointSuffix])
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,52 +2,40 @@ package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func TestInferTypeUnsupported(t *testing.T) {
|
||||
if got := inferType(reflect.TypeFor[map[string]int]()); got != "" {
|
||||
t.Errorf("map: got %q, want empty", got)
|
||||
}
|
||||
if got := inferType(reflect.TypeFor[chan int]()); got != "" {
|
||||
t.Errorf("chan: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
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")
|
||||
})
|
||||
|
||||
func TestInferType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"string", "", TypeKeyword},
|
||||
{"*string", (*string)(nil), TypeKeyword},
|
||||
{"[]string", []string(nil), TypeKeyword},
|
||||
{"bool", false, TypeBool},
|
||||
{"int", int(0), TypeNumeric},
|
||||
{"int64", int64(0), TypeNumeric},
|
||||
{"uint64", uint64(0), TypeNumeric},
|
||||
{"float64", float64(0), TypeNumeric},
|
||||
{"time.Time", time.Time{}, TypeDatetime},
|
||||
{"*time.Time", (*time.Time)(nil), TypeDatetime},
|
||||
{"*timestamppb.Timestamp", (*timestamppb.Timestamp)(nil), TypeDatetime},
|
||||
{"struct", struct{ X int }{}, TypeObject},
|
||||
{"*struct", (*struct{ X int })(nil), TypeObject},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := inferType(reflect.TypeOf(c.in))
|
||||
if got != c.want {
|
||||
t.Fatalf("inferType(%s): got %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
})
|
||||
|
||||
func TestResolveField(t *testing.T) {
|
||||
var _ = Describe("resolveField", func() {
|
||||
type S struct {
|
||||
Exported string `json:"exp"`
|
||||
Renamed string `json:"renamed,omitempty"`
|
||||
@@ -57,69 +45,56 @@ func TestResolveField(t *testing.T) {
|
||||
unexported string //nolint:unused
|
||||
}
|
||||
st := reflect.TypeFor[S]()
|
||||
cases := []struct {
|
||||
fieldIdx int
|
||||
wantName string
|
||||
wantSkip bool
|
||||
}{
|
||||
{0, "exp", false},
|
||||
{1, "renamed", false},
|
||||
{2, "NoTag", false},
|
||||
{3, "OmitOnly", false},
|
||||
{4, "", true},
|
||||
{5, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
fi := resolveField(st.Field(c.fieldIdx))
|
||||
if fi.Skip != c.wantSkip {
|
||||
t.Errorf("field %d: skip=%v, want %v", c.fieldIdx, fi.Skip, c.wantSkip)
|
||||
}
|
||||
if !c.wantSkip && fi.Name != c.wantName {
|
||||
t.Errorf("field %d: name=%q, want %q", c.fieldIdx, fi.Name, c.wantName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkFieldsFlattensEmbedded(t *testing.T) {
|
||||
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
|
||||
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"}))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walkFields: %v", err)
|
||||
}
|
||||
want := []string{"a", "b", "c"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("got %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
func TestStructType(t *testing.T) {
|
||||
var _ = Describe("structType", func() {
|
||||
type S struct{ X int }
|
||||
cases := []struct {
|
||||
name string
|
||||
in reflect.Type
|
||||
wantNil bool
|
||||
}{
|
||||
{"struct", reflect.TypeFor[S](), false},
|
||||
{"*struct", reflect.TypeFor[*S](), false},
|
||||
{"[]struct", reflect.TypeFor[[]S](), false},
|
||||
{"time.Time", reflect.TypeFor[time.Time](), true},
|
||||
{"string", reflect.TypeFor[string](), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := structType(c.in)
|
||||
if (got == nil) != c.wantNil {
|
||||
t.Errorf("%s: got %v, wantNil %v", c.name, got, c.wantNil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
})
|
||||
|
||||
13
services/search/pkg/mapping/mapping_suite_test.go
Normal file
13
services/search/pkg/mapping/mapping_suite_test.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type osDoc struct {
|
||||
@@ -18,154 +20,117 @@ type osDoc struct {
|
||||
} `json:"nested,omitempty"`
|
||||
}
|
||||
|
||||
func TestOpenSearchNumericTypes(t *testing.T) {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSearchBuildMapping: %v", err)
|
||||
}
|
||||
want := map[string]string{"a": "short", "b": "short", "c": "integer", "d": "long", "e": "short", "f": "long", "g": "float", "h": "double"}
|
||||
for k, wt := range want {
|
||||
if got := props[k].(map[string]any)["type"]; got != wt {
|
||||
t.Errorf("%s: type = %v, want %v", k, got, wt)
|
||||
}
|
||||
}
|
||||
}
|
||||
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"),
|
||||
)
|
||||
|
||||
func TestOpenSearchBuildMappingInferred(t *testing.T) {
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSearchBuildMapping: %v", err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"ID": "keyword",
|
||||
"Size": "long",
|
||||
"Deleted": "boolean",
|
||||
"CreatedAt": "date",
|
||||
"Rating": "double",
|
||||
}
|
||||
for k, v := range want {
|
||||
m, ok := props[k].(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("%s: missing or not a map: %#v", k, props[k])
|
||||
continue
|
||||
}
|
||||
if got := m["type"]; got != v {
|
||||
t.Errorf("%s: type %v, want %v", k, got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
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"),
|
||||
)
|
||||
|
||||
func TestOpenSearchBuildMappingNested(t *testing.T) {
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[osDoc](), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSearchBuildMapping: %v", err)
|
||||
}
|
||||
nested, ok := props["nested"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nested: not a map: %#v", props["nested"])
|
||||
}
|
||||
sub, ok := nested["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nested.properties: missing: %#v", nested)
|
||||
}
|
||||
artist, ok := sub["artist"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nested.artist: %#v", sub)
|
||||
}
|
||||
if artist["type"] != "keyword" {
|
||||
t.Errorf("nested.artist.type: %v", artist["type"])
|
||||
}
|
||||
year, ok := sub["year"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nested.year: %#v", sub)
|
||||
}
|
||||
if year["type"] != "integer" {
|
||||
t.Errorf("nested.year.type: %v (int32 → integer expected)", year["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBuildMappingOverrides(t *testing.T) {
|
||||
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": {Analyzer: "lowercaseKeyword"},
|
||||
"Content": {Type: TypeFulltext},
|
||||
"Path": {Type: TypePath},
|
||||
"MimeType": {Type: TypeWildcard},
|
||||
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)")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSearchBuildMapping: %v", err)
|
||||
}
|
||||
name := props["Name"].(map[string]any)
|
||||
if name["type"] != "text" || name["analyzer"] != "lowercaseKeyword" {
|
||||
t.Errorf("Name: %#v", name)
|
||||
}
|
||||
content := props["Content"].(map[string]any)
|
||||
if content["type"] != "text" || content["term_vector"] != "with_positions_offsets" {
|
||||
t.Errorf("Content: %#v", content)
|
||||
}
|
||||
if _, ok := content["analyzer"]; ok {
|
||||
t.Errorf("Content should leave analyzer unset (use OpenSearch default), got %#v", content["analyzer"])
|
||||
}
|
||||
path := props["Path"].(map[string]any)
|
||||
if path["type"] != "text" || path["analyzer"] != "path_hierarchy" {
|
||||
t.Errorf("Path: %#v", path)
|
||||
}
|
||||
mime := props["MimeType"].(map[string]any)
|
||||
if mime["type"] != "wildcard" {
|
||||
t.Errorf("MimeType: %#v", mime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSearchBuildMappingGeopoint(t *testing.T) {
|
||||
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},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSearchBuildMapping: %v", err)
|
||||
}
|
||||
// Object for libregraph-shape data retrieval.
|
||||
loc, ok := props["location"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("location: %#v", props["location"])
|
||||
}
|
||||
sub, ok := loc["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("location should have numeric sub-properties, got %#v", loc)
|
||||
}
|
||||
for _, k := range []string{"longitude", "latitude", "altitude"} {
|
||||
prop, ok := sub[k].(map[string]any)
|
||||
if !ok || prop["type"] != "double" {
|
||||
t.Errorf("location.%s: %#v", k, sub[k])
|
||||
It("applies field overrides", func() {
|
||||
type doc struct {
|
||||
Name string `json:"Name"`
|
||||
Content string `json:"Content"`
|
||||
Path string `json:"Path"`
|
||||
MimeType string `json:"MimeType"`
|
||||
}
|
||||
}
|
||||
// Sibling geo_point for spatial queries.
|
||||
gp, ok := props["location"+GeopointSuffix].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("location%s: %#v", GeopointSuffix, props["location"+GeopointSuffix])
|
||||
}
|
||||
if gp["type"] != "geo_point" {
|
||||
t.Errorf("location%s.type: %v", GeopointSuffix, gp["type"])
|
||||
}
|
||||
}
|
||||
props, err := OpenSearchBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{
|
||||
"Name": {Analyzer: "lowercaseKeyword"},
|
||||
"Content": {Type: TypeFulltext},
|
||||
"Path": {Type: TypePath},
|
||||
"MimeType": {Type: TypeWildcard},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
name := props["Name"].(map[string]any)
|
||||
Expect(name["type"]).To(Equal("text"), "Name: %#v", name)
|
||||
Expect(name["analyzer"]).To(Equal("lowercaseKeyword"), "Name: %#v", name)
|
||||
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)
|
||||
_, ok := content["analyzer"]
|
||||
Expect(ok).To(BeFalse(), "Content should leave analyzer unset (use OpenSearch default)")
|
||||
path := props["Path"].(map[string]any)
|
||||
Expect(path["type"]).To(Equal("text"), "Path: %#v", path)
|
||||
Expect(path["analyzer"]).To(Equal("path_hierarchy"), "Path: %#v", path)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,83 +1,67 @@
|
||||
package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPrepareForIndexError(t *testing.T) {
|
||||
// a func field can't be json-marshalled -> conversions.To errors
|
||||
type bad struct {
|
||||
F func() `json:"f"`
|
||||
}
|
||||
if _, err := PrepareForIndex(bad{}, nil); err == nil {
|
||||
t.Error("expected error for non-marshallable value")
|
||||
}
|
||||
}
|
||||
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())
|
||||
})
|
||||
|
||||
func TestPrepareForIndexNil(t *testing.T) {
|
||||
// a typed nil pointer marshals to null -> nil map, no error, no panic
|
||||
out, err := PrepareForIndex((*struct{})(nil), nil)
|
||||
if err != nil || out != nil {
|
||||
t.Errorf("got (%v, %v), want (nil, nil)", out, err)
|
||||
}
|
||||
}
|
||||
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())
|
||||
})
|
||||
|
||||
func TestPrepareForIndexFlattensEmbedded(t *testing.T) {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForIndex: %v", err)
|
||||
}
|
||||
want := map[string]any{"Name": "a", "Size": float64(7), "ID": "x"}
|
||||
if !reflect.DeepEqual(m, want) {
|
||||
t.Fatalf("got %#v, want %#v", m, want)
|
||||
}
|
||||
}
|
||||
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))
|
||||
})
|
||||
|
||||
func TestPrepareForIndexOmitsNilWithOmitempty(t *testing.T) {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForIndex: %v", err)
|
||||
}
|
||||
if _, ok := m["audio"]; ok {
|
||||
t.Errorf("audio should be omitted when nil: %#v", m)
|
||||
}
|
||||
if m["Name"] != "n" {
|
||||
t.Errorf("Name: %v", m["Name"])
|
||||
}
|
||||
}
|
||||
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"))
|
||||
})
|
||||
|
||||
func TestPrepareForIndexIncludesNestedWhenSet(t *testing.T) {
|
||||
type facet struct {
|
||||
Artist string `json:"artist"`
|
||||
}
|
||||
type doc struct {
|
||||
Audio *facet `json:"audio,omitempty"`
|
||||
}
|
||||
m, err := PrepareForIndex(doc{Audio: &facet{Artist: "A"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareForIndex: %v", err)
|
||||
}
|
||||
nested, ok := m["audio"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("audio should be a nested map: %#v", m["audio"])
|
||||
}
|
||||
if nested["artist"] != "A" {
|
||||
t.Errorf("audio.artist: %v", nested["artist"])
|
||||
}
|
||||
}
|
||||
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"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,9 @@ package mapping
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type inner struct {
|
||||
@@ -19,33 +20,28 @@ type sample struct {
|
||||
} `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
func TestValidateAccepts(t *testing.T) {
|
||||
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
|
||||
"Name": {Analyzer: "lowercaseKeyword"},
|
||||
"audio": {Type: TypeObject},
|
||||
"audio.artist": {Analyzer: "lowercaseKeyword"},
|
||||
"location": {Type: TypeGeopoint},
|
||||
var _ = Describe("Validate", func() {
|
||||
It("accepts known override keys", func() {
|
||||
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
|
||||
"Name": {Analyzer: "lowercaseKeyword"},
|
||||
"audio": {Type: TypeObject},
|
||||
"audio.artist": {Analyzer: "lowercaseKeyword"},
|
||||
"location": {Type: TypeGeopoint},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknown(t *testing.T) {
|
||||
err := Validate(reflect.TypeFor[sample](), map[string]FieldOpts{
|
||||
"nope": {},
|
||||
"audio.zzz": {},
|
||||
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"))
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nope") || !strings.Contains(err.Error(), "audio.zzz") {
|
||||
t.Fatalf("error missing keys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmpty(t *testing.T) {
|
||||
if err := Validate(reflect.TypeFor[sample](), nil); err != nil {
|
||||
t.Fatalf("empty overrides should pass: %v", err)
|
||||
}
|
||||
}
|
||||
It("accepts empty overrides", func() {
|
||||
Expect(Validate(reflect.TypeFor[sample](), nil)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user