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