refactor(search): resolve geo fields via mapping overrides, reject non-geo fields

Replaces the ad-hoc key+"_geopoint" concatenation with a proper lookup
(query.ResolveGeoField) derived from the TypeGeopoint field overrides, so it
works for any geopoint field, not just location. Geo predicates on non-geopoint
fields now error instead of querying a nonexistent field.
This commit is contained in:
Dominik Schmidt committed 2026-09-03 01:58:10 +02:00
1 parent 95a953ad86
commit ea47069d7c
5 files changed
+79 -14

No files matched your search

@@ -45,4 +45,9 @@ var _ = Describe("Geo KQL predicates", func() {
Expect(hits(near + " AND location:geo.bbox(49.0, 11.0, 50.0, 12.0)")).To(Equal(1))
Expect(hits(near + " AND location:geo.bbox(52.0, 13.0, 53.0, 14.0)")).To(Equal(0))
})
It("rejects a geo predicate on a non-geopoint field", func() {
_, err := qbleve.DefaultCreator.Create("name:geo.distance(48.2, 16.3, 5km)")
Expect(err).To(HaveOccurred())
})
})
@@ -15,10 +15,14 @@ import (
"github.com/opencloud-eu/opencloud/services/search/pkg/query"
)
// geoField maps a KQL geo key to its indexed geopoint sibling, e.g.
// "location" to "location_geopoint".
func geoField(key string) string {
return strings.ToLower(key) + mapping.GeopointSuffix
// geoQueryField resolves a KQL geo key to its indexed geopoint field, erroring
// when the key is not a geopoint field.
func geoQueryField(key string) (string, error) {
field, ok := query.ResolveGeoField(key)
if !ok {
return "", fmt.Errorf("geo predicate on non-geo field %q", key)
}
return field, nil
}
func TranspileKQLToOpenSearch(nodes []ast.Node) (osu.Builder, error) {
@@ -193,14 +197,26 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) {
return group, nil
case *ast.GeoDistanceNode:
return osu.NewGeoDistanceQuery(geoField(node.Key)).
field, err := geoQueryField(node.Key)
if err != nil {
return nil, err
}
return osu.NewGeoDistanceQuery(field).
Distance(strconv.FormatFloat(node.Radius, 'f', -1, 64)+"m").
Point(node.Lat, node.Lon), nil
case *ast.GeoBoundingBoxNode:
return osu.NewGeoBoundingBoxQuery(geoField(node.Key)).
field, err := geoQueryField(node.Key)
if err != nil {
return nil, err
}
return osu.NewGeoBoundingBoxQuery(field).
Box(node.MinLat, node.MinLon, node.MaxLat, node.MaxLon), nil
case *ast.GeoPolygonNode:
q := osu.NewGeoPolygonQuery(geoField(node.Key))
field, err := geoQueryField(node.Key)
if err != nil {
return nil, err
}
q := osu.NewGeoPolygonQuery(field)
for _, p := range node.Points {
q.Point(p.Lat, p.Lon)
}
@@ -473,3 +473,10 @@ func TestTranspileKQLToOpenSearch(t *testing.T) {
})
}
}
func TestTranspileRejectsGeoOnNonGeoField(t *testing.T) {
_, err := convert.TranspileKQLToOpenSearch([]ast.Node{
&ast.GeoDistanceNode{Key: "name", Lat: 48.2, Lon: 16.3, Radius: 5000},
})
assert.Error(t, err)
}
+23 -7
View File
@@ -42,10 +42,14 @@ var bleveEscaper = strings.NewReplacer(
` `, `\ `,
)
// geoField maps a KQL geo key to its indexed geopoint sibling, e.g.
// "location" to "location_geopoint".
func geoField(key string) string {
return strings.ToLower(key) + mapping.GeopointSuffix
// geoQueryField resolves a KQL geo key to its indexed geopoint field, erroring
// when the key is not a geopoint field.
func geoQueryField(key string) (string, error) {
field, ok := searchQuery.ResolveGeoField(key)
if !ok {
return "", fmt.Errorf("geo predicate on non-geo field %q", key)
}
return field, nil
}
// Compiler represents a KQL query search string to the bleve query formatter.
@@ -237,29 +241,41 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) {
next = q
}
case *ast.GeoDistanceNode:
field, err := geoQueryField(n.Key)
if err != nil {
return nil, 0, err
}
q := bleveQuery.NewGeoDistanceQuery(n.Lon, n.Lat, strconv.FormatFloat(n.Radius, 'f', -1, 64)+"m")
q.SetField(geoField(n.Key))
q.SetField(field)
if prev == nil {
prev = q
} else {
next = q
}
case *ast.GeoBoundingBoxNode:
field, err := geoQueryField(n.Key)
if err != nil {
return nil, 0, err
}
// bleve takes the top-left and bottom-right corners.
q := bleveQuery.NewGeoBoundingBoxQuery(n.MinLon, n.MaxLat, n.MaxLon, n.MinLat)
q.SetField(geoField(n.Key))
q.SetField(field)
if prev == nil {
prev = q
} else {
next = q
}
case *ast.GeoPolygonNode:
field, err := geoQueryField(n.Key)
if err != nil {
return nil, 0, err
}
points := make([]geo.Point, 0, len(n.Points))
for _, p := range n.Points {
points = append(points, geo.Point{Lon: p.Lon, Lat: p.Lat})
}
q := bleveQuery.NewGeoBoundingPolygonQuery(points)
q.SetField(geoField(n.Key))
q.SetField(field)
if prev == nil {
prev = q
} else {
+21
View File
@@ -102,3 +102,24 @@ func FieldIsFulltext(field string) bool {
func FieldIsWordBroken(field string) bool {
return siblingFields()[field].Words
}
// geopointFields maps a lowercased KQL key to its indexed geopoint sibling field,
// derived from the TypeGeopoint entries in the resource field overrides (e.g.
// "location" -> "location_geopoint", "journey.start" -> "journey.start_geopoint").
var geopointFields = sync.OnceValue(func() map[string]string {
out := map[string]string{}
for key, opts := range (search.Resource{}).SearchFieldOverrides() {
if opts.Type == mapping.TypeGeopoint {
out[strings.ToLower(key)] = key + mapping.GeopointSuffix
}
}
return out
})
// ResolveGeoField maps a KQL key to its indexed geopoint field name. ok is false
// when the key is not a geopoint field, so callers can reject geo predicates on
// non-geo fields.
func ResolveGeoField(name string) (string, bool) {
f, ok := geopointFields()[strings.ToLower(name)]
return f, ok
}