test(search): convert mapping package tests to ginkgo

New package, so use the repo's standard test framework.
This commit is contained in:
Dominik Schmidt
2026-07-05 16:56:10 +02:00
parent 22f46dd9d4
commit 431c97e712
10 changed files with 672 additions and 883 deletions

View File

@@ -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)
})
})

View File

@@ -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())
})
})

View File

@@ -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)))
})
})

View File

@@ -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")
})
}
})

View File

@@ -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))
})
})

View File

@@ -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),
)
})

View 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")
}

View File

@@ -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)
})
})

View File

@@ -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"))
})
})

View File

@@ -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())
})
})