From e43fe130669401b5126bbb4e29eee5190c7b8a5d Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Fri, 31 Jul 2026 09:42:30 +0200 Subject: [PATCH] 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. --- pkg/ast/ast.go | 4 +++- pkg/kql/factory.go | 8 ++++++++ pkg/kql/geo_test.go | 11 +++++++++++ services/search/pkg/bleve/geo_kql_test.go | 6 ++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pkg/ast/ast.go b/pkg/ast/ast.go index f82aa4d117..018dcf3557 100644 --- a/pkg/ast/ast.go +++ b/pkg/ast/ast.go @@ -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 diff --git a/pkg/kql/factory.go b/pkg/kql/factory.go index 8106533f26..4895151431 100644 --- a/pkg/kql/factory.go +++ b/pkg/kql/factory.go @@ -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 } diff --git a/pkg/kql/geo_test.go b/pkg/kql/geo_test.go index 7a4f684d46..9977de4331 100644 --- a/pkg/kql/geo_test.go +++ b/pkg/kql/geo_test.go @@ -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()) diff --git a/services/search/pkg/bleve/geo_kql_test.go b/services/search/pkg/bleve/geo_kql_test.go index 09686ef7b4..5e9c887561 100644 --- a/services/search/pkg/bleve/geo_kql_test.go +++ b/services/search/pkg/bleve/geo_kql_test.go @@ -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())