Files
opencloud/services/search/pkg/mapping/bleve_test.go
Dominik Schmidt ba210232ab feat(search): analyze Content by words on both engines, no stemming
Adopts the parity-pinned semantics from #3408: 'report' does not match 'reports'; the porter fulltext analyzer is gone.
2026-08-31 13:40:42 +02:00

139 lines
6.1 KiB
Go

package mapping
import (
"reflect"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type bleveDoc struct {
Name string `json:"Name"`
Content string `json:"Content"`
Tags []string `json:"Tags"`
Size uint64 `json:"Size"`
Deleted bool `json:"Deleted"`
CreatedAt time.Time `json:"CreatedAt"`
Nested *nested `json:"nested,omitempty"`
}
type nested struct {
Artist string `json:"artist"`
Year int `json:"year"`
}
var _ = Describe("BleveBuildMapping", func() {
It("falls back to text for wildcard fields", func() {
// bleve wildcard falls back to keyword-ish text (bleve has no wildcard type).
type doc struct {
Mime string `json:"mime"`
}
dm, err := BleveBuildMapping(reflect.TypeFor[doc](), map[string]FieldOpts{"mime": {Type: TypeWildcard}})
Expect(err).ToNot(HaveOccurred())
fms := dm.Properties["mime"].Fields
Expect(fms).To(HaveLen(1))
Expect(fms[0].Type).To(Equal("text"), "wildcard should map to text")
})
DescribeTable("infers field types",
func(field, wantType string) {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
prop := dm.Properties[field]
Expect(prop).ToNot(BeNil(), "missing property %q", field)
Expect(prop.Fields).ToNot(BeEmpty(), "%q: no field mappings", field)
Expect(prop.Fields[0].Type).To(Equal(wantType), "%q type", field)
},
Entry("Name", "Name", "text"),
Entry("Content", "Content", "text"),
Entry("Tags", "Tags", "text"),
Entry("Size", "Size", "number"),
Entry("Deleted", "Deleted", "boolean"),
Entry("CreatedAt", "CreatedAt", "datetime"),
)
It("maps a nested struct as a sub-document", func() {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
sub := dm.Properties["nested"]
Expect(sub).ToNot(BeNil(), "missing nested sub-document")
Expect(sub.Properties["artist"]).ToNot(BeNil())
Expect(sub.Properties["year"]).ToNot(BeNil())
Expect(sub.Properties["artist"].Fields[0].Type).To(Equal("text"), "nested.artist")
Expect(sub.Properties["year"].Fields[0].Type).To(Equal("number"), "nested.year")
})
It("applies field overrides", func() {
True, False := true, false
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), map[string]FieldOpts{
"Name": {CaseInsensitive: &True},
"Content": {Type: TypeFulltext},
"Tags": {CaseInsensitive: &True, IncludeInAll: &False},
})
Expect(err).ToNot(HaveOccurred())
// Name: case-preserved base keyword + lowercased sibling.
Expect(dm.Properties["Name"]).ToNot(BeNil(), "Name base field")
Expect(dm.Properties["Name"].Fields[0].Analyzer).To(Equal("keyword"), "Name base is a keyword")
Expect(dm.Properties["Name"].Fields[0].Store).To(BeTrue(), "Name base is stored (returned)")
Expect(dm.Properties["Name_lowercase"]).ToNot(BeNil(), "Name_lowercase sibling")
// The sibling is a search-only shadow: indexed but never stored, kept out
// of _all, no doc values (the base is what we return).
sibling := dm.Properties["Name_lowercase"].Fields[0]
Expect(sibling.Index).To(BeTrue(), "Name_lowercase is indexed")
Expect(sibling.Store).To(BeFalse(), "Name_lowercase is not stored")
Expect(sibling.IncludeInAll).To(BeFalse(), "Name_lowercase is out of _all")
Expect(sibling.DocValues).To(BeFalse(), "Name_lowercase has no doc values")
contentField := dm.Properties["Content"].Fields[0]
Expect(contentField.Analyzer).To(Equal(WordsAnalyzer), "Content analyzer")
Expect(contentField.IncludeInAll).To(BeFalse(), "Content IncludeInAll should default to false for the fulltext type")
// Tags: base + lowercased sibling, both honoring the IncludeInAll override.
Expect(dm.Properties["Tags"].Fields[0].IncludeInAll).To(BeFalse(), "Tags base IncludeInAll honored")
Expect(dm.Properties["Tags_lowercase"].Fields[0].IncludeInAll).To(BeFalse(), "Tags sibling IncludeInAll honored")
})
It("gives a keyword its lowercase and words siblings by default", func() {
dm, err := BleveBuildMapping(reflect.TypeFor[bleveDoc](), nil)
Expect(err).ToNot(HaveOccurred())
// the base stays a keyword, the words go to a search-only sibling
Expect(dm.Properties["Name"].Fields[0].Analyzer).To(Equal("keyword"), "Name base stays a keyword")
Expect(dm.Properties["Name"].Fields[0].Store).To(BeTrue(), "Name base is stored (returned)")
Expect(dm.Properties["Name_lowercase"].Fields[0].Analyzer).To(Equal("keyword"), "Name_lowercase stays a keyword")
words := dm.Properties["Name_words"].Fields[0]
Expect(words.Analyzer).To(Equal(WordsAnalyzer), "Name_words is split into words")
Expect(words.Store).To(BeFalse(), "Name_words is not stored")
Expect(words.IncludeInAll).To(BeFalse(), "Name_words is out of _all")
})
It("builds an object sub-document plus a geopoint sibling", func() {
type geoDoc struct {
Location *struct {
Lon *float64 `json:"longitude,omitempty"`
Lat *float64 `json:"latitude,omitempty"`
Alt *float64 `json:"altitude,omitempty"`
} `json:"location,omitempty"`
}
dm, err := BleveBuildMapping(reflect.TypeFor[geoDoc](), map[string]FieldOpts{
"location": {Type: TypeGeopoint},
})
Expect(err).ToNot(HaveOccurred())
// Original facet stays as an object sub-document with numeric
// sub-properties - for data retrieval via hit.Fields and ordinary
// numeric queries.
loc := dm.Properties["location"]
Expect(loc).ToNot(BeNil(), "location sub-document missing")
Expect(loc.Fields).To(BeEmpty(), "location should not carry field mappings directly")
for _, sub := range []string{"longitude", "latitude", "altitude"} {
prop, ok := loc.Properties[sub]
Expect(ok).To(BeTrue(), "missing sub-field %q under location", sub)
Expect(prop.Fields).ToNot(BeEmpty(), "location.%s Fields", sub)
Expect(prop.Fields[0].Type).To(Equal("number"), "location.%s type", sub)
}
// Sibling geopoint at "<name>_geopoint" for geo-distance queries.
sibling := dm.Properties["location"+GeopointSuffix]
Expect(sibling).ToNot(BeNil(), "location%s missing", GeopointSuffix)
Expect(sibling.Fields).ToNot(BeEmpty(), "location%s Fields", GeopointSuffix)
Expect(sibling.Fields[0].Type).To(Equal("geopoint"), "location%s type", GeopointSuffix)
})
})