fix(kql): normalise geo.bbox latitude order for backend-consistent behaviour

Sorts the two latitudes in the KQL layer so MinLat <= MaxLat. Only bleve
strictly requires it (it errors on an inverted box; OpenSearch tolerates it),
but doing it centrally keeps behaviour consistent across backends and the AST
easy to reason about. Longitude order is preserved so an antimeridian-crossing
box (minLon > maxLon) still works, which both backends handle identically.
This commit is contained in:
Dominik Schmidt committed 2026-09-03 01:58:11 +02:00
1 parent ea47069d7c
commit e43fe13066
4 files changed
+28 -1

No files matched your search

+3 -1
View File
@@ -101,7 +101,9 @@ type GeoDistanceNode struct {
Radius float64
}
// GeoBoundingBoxNode represents a geo.bbox(minLat, minLon, maxLat, maxLon) predicate.
// GeoBoundingBoxNode represents a geo.bbox(minLat, minLon, maxLat, maxLon)
// predicate. Latitude is normalised so MinLat <= MaxLat; longitude order is
// preserved, so MinLon > MaxLon denotes a box crossing the antimeridian.
type GeoBoundingBoxNode struct {
*Base
Key string
+8
View File
@@ -276,6 +276,14 @@ func buildGeoBoundingBoxNode(k, a any, text []byte, pos position) (*ast.GeoBound
if err != nil {
return nil, err
}
// Latitude does not wrap, so the two values just delimit the box. Only bleve
// strictly requires MinLat <= MaxLat (OpenSearch tolerates the inverted
// order), but we normalise centrally here for consistency and easier
// debugging. Longitude order is kept: minLon > maxLon denotes a box crossing
// the antimeridian, which both backends interpret the same way.
if minLat > maxLat {
minLat, maxLat = maxLat, minLat
}
return &ast.GeoBoundingBoxNode{Base: b, Key: key, MinLat: minLat, MinLon: minLon, MaxLat: maxLat, MaxLon: maxLon}, nil
}
+11
View File
@@ -52,6 +52,17 @@ var _ = Describe("geo KQL predicates", func() {
}))
})
It("normalises inverted latitude order but keeps longitude order", func() {
// minLat (48.3) > maxLat (47.9) on input; longitude kept as given.
n, err := firstNode("location:geo.bbox(48.3, 16.1, 47.9, 16.5)")
Expect(err).ToNot(HaveOccurred())
b := n.(*ast.GeoBoundingBoxNode)
Expect(b.MinLat).To(Equal(47.9))
Expect(b.MaxLat).To(Equal(48.3))
Expect(b.MinLon).To(Equal(16.1))
Expect(b.MaxLon).To(Equal(16.5))
})
It("parses geo.polygon into a GeoPolygonNode with its vertices", func() {
n, err := firstNode("location:geo.polygon(48.3 16.1, 48.3 16.5, 47.9 16.5)")
Expect(err).ToNot(HaveOccurred())
@@ -46,6 +46,12 @@ var _ = Describe("Geo KQL predicates", func() {
Expect(hits(near + " AND location:geo.bbox(52.0, 13.0, 53.0, 14.0)")).To(Equal(0))
})
It("matches a bbox given with inverted latitude order", func() {
// Latitude args inverted (max, ..., min): normalised centrally so bleve
// gets a well-formed box instead of erroring. Same box as the passing case.
Expect(hits("location:geo.bbox(50.0, 11.0, 49.0, 12.0)")).To(Equal(1))
})
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())