feat(kql): geo.distance, geo.bbox and geo.polygon predicates

Extends the KQL grammar with geo function values on any field, e.g.
location:geo.distance(lat, lon, 5km). Parses into GeoDistance/GeoBoundingBox/
GeoPolygon AST nodes; radius units (km/m/mi) are normalised to meters.
This commit is contained in:
Dominik Schmidt committed 2026-10-15 19:29:56 +02:00
1 parent ddd0eda688
commit b3cbf84b01
7 files changed
+1407 -507

No files matched your search

+38
View File
@@ -86,6 +86,38 @@ type GroupNode struct {
Nodes []Node
}
// GeoPoint is a lat/lon vertex.
type GeoPoint struct {
Lat float64
Lon float64
}
// GeoDistanceNode represents a geo.distance(lat, lon, radius) predicate; Radius is in meters.
type GeoDistanceNode struct {
*Base
Key string
Lat float64
Lon float64
Radius float64
}
// GeoBoundingBoxNode represents a geo.bbox(minLat, minLon, maxLat, maxLon) predicate.
type GeoBoundingBoxNode struct {
*Base
Key string
MinLat float64
MinLon float64
MaxLat float64
MaxLon float64
}
// GeoPolygonNode represents a geo.polygon(lat lon, ...) predicate.
type GeoPolygonNode struct {
*Base
Key string
Points []GeoPoint
}
// NodeKey tries to return the node key
func NodeKey(n Node) string {
switch node := n.(type) {
@@ -97,6 +129,12 @@ func NodeKey(n Node) string {
return node.Key
case *GroupNode:
return node.Key
case *GeoDistanceNode:
return node.Key
case *GeoBoundingBoxNode:
return node.Key
case *GeoPolygonNode:
return node.Key
default:
return ""
}
+21
View File
@@ -41,6 +41,7 @@ PropertyRestrictionNodes <-
YesNoPropertyRestrictionNode /
DateTimeRestrictionNode /
NumberRestrictionNode /
GeoPropertyRestrictionNode /
TextPropertyRestrictionNode
YesNoPropertyRestrictionNode <-
@@ -88,6 +89,26 @@ TextPropertyRestrictionNode <-
return buildStringNode(k, v, false, c.text, c.pos)
}
////////////////////////////////////////////////////////
// geo property restrictions
////////////////////////////////////////////////////////
GeoPropertyRestrictionNode <-
k:Key (OperatorColonNode / OperatorEqualNode) "geo.distance(" a:GeoArgs ")" {
return buildGeoDistanceNode(k, a, c.text, c.pos)
} /
k:Key (OperatorColonNode / OperatorEqualNode) "geo.bbox(" a:GeoArgs ")" {
return buildGeoBoundingBoxNode(k, a, c.text, c.pos)
} /
k:Key (OperatorColonNode / OperatorEqualNode) "geo.polygon(" a:GeoArgs ")" {
return buildGeoPolygonNode(k, a, c.text, c.pos)
}
GeoArgs <-
[^)]* {
return string(c.text), nil
}
////////////////////////////////////////////////////////
// free text-keywords
////////////////////////////////////////////////////////
+1095 -507
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -238,3 +238,63 @@ func buildGroupNode(k, n any, text []byte, pos position) (*ast.GroupNode, error)
return gn, nil
}
func buildGeoDistanceNode(k, a any, text []byte, pos position) (*ast.GeoDistanceNode, error) {
b, err := base(text, pos)
if err != nil {
return nil, err
}
key, err := toString(k)
if err != nil {
return nil, err
}
args, err := toString(a)
if err != nil {
return nil, err
}
lat, lon, radius, err := parseGeoDistanceArgs(args)
if err != nil {
return nil, err
}
return &ast.GeoDistanceNode{Base: b, Key: key, Lat: lat, Lon: lon, Radius: radius}, nil
}
func buildGeoBoundingBoxNode(k, a any, text []byte, pos position) (*ast.GeoBoundingBoxNode, error) {
b, err := base(text, pos)
if err != nil {
return nil, err
}
key, err := toString(k)
if err != nil {
return nil, err
}
args, err := toString(a)
if err != nil {
return nil, err
}
minLat, minLon, maxLat, maxLon, err := parseGeoBoundingBoxArgs(args)
if err != nil {
return nil, err
}
return &ast.GeoBoundingBoxNode{Base: b, Key: key, MinLat: minLat, MinLon: minLon, MaxLat: maxLat, MaxLon: maxLon}, nil
}
func buildGeoPolygonNode(k, a any, text []byte, pos position) (*ast.GeoPolygonNode, error) {
b, err := base(text, pos)
if err != nil {
return nil, err
}
key, err := toString(k)
if err != nil {
return nil, err
}
args, err := toString(a)
if err != nil {
return nil, err
}
points, err := parseGeoPolygonArgs(args)
if err != nil {
return nil, err
}
return &ast.GeoPolygonNode{Base: b, Key: key, Points: points}, nil
}
+95
View File
@@ -0,0 +1,95 @@
package kql
import (
"fmt"
"strconv"
"strings"
"github.com/opencloud-eu/opencloud/pkg/ast"
)
// radiusUnits maps a distance suffix to its length in meters. Ordered longest
// first so parseRadius matches "km"/"mi" before the bare "m".
var radiusUnits = []struct {
suffix string
meters float64
}{
{"km", 1000},
{"mi", 1609.344},
{"m", 1},
}
// parseRadius parses a distance like "5km", "500m" or "3mi" into meters.
func parseRadius(s string) (float64, error) {
s = strings.TrimSpace(s)
for _, u := range radiusUnits {
if num, ok := strings.CutSuffix(s, u.suffix); ok {
v, err := strconv.ParseFloat(strings.TrimSpace(num), 64)
if err != nil {
return 0, fmt.Errorf("invalid radius %q: %w", s, err)
}
return v * u.meters, nil
}
}
return 0, fmt.Errorf("radius %q needs a unit (km, m, mi)", s)
}
func parseCoord(s string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(s), 64)
}
// parseGeoDistanceArgs parses "lat, lon, radius".
func parseGeoDistanceArgs(s string) (lat, lon, radius float64, err error) {
parts := strings.Split(s, ",")
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("geo.distance expects lat, lon, radius, got %q", s)
}
if lat, err = parseCoord(parts[0]); err != nil {
return
}
if lon, err = parseCoord(parts[1]); err != nil {
return
}
radius, err = parseRadius(parts[2])
return
}
// parseGeoBoundingBoxArgs parses "minLat, minLon, maxLat, maxLon".
func parseGeoBoundingBoxArgs(s string) (minLat, minLon, maxLat, maxLon float64, err error) {
parts := strings.Split(s, ",")
if len(parts) != 4 {
return 0, 0, 0, 0, fmt.Errorf("geo.bbox expects minLat, minLon, maxLat, maxLon, got %q", s)
}
coords := make([]float64, 4)
for i, p := range parts {
if coords[i], err = parseCoord(p); err != nil {
return
}
}
return coords[0], coords[1], coords[2], coords[3], nil
}
// parseGeoPolygonArgs parses "lat lon, lat lon, ..." into at least three vertices.
func parseGeoPolygonArgs(s string) ([]ast.GeoPoint, error) {
parts := strings.Split(s, ",")
if len(parts) < 3 {
return nil, fmt.Errorf("geo.polygon needs at least 3 points, got %q", s)
}
points := make([]ast.GeoPoint, 0, len(parts))
for _, p := range parts {
fields := strings.Fields(p)
if len(fields) != 2 {
return nil, fmt.Errorf("geo.polygon point %q must be 'lat lon'", strings.TrimSpace(p))
}
lat, err := parseCoord(fields[0])
if err != nil {
return nil, err
}
lon, err := parseCoord(fields[1])
if err != nil {
return nil, err
}
points = append(points, ast.GeoPoint{Lat: lat, Lon: lon})
}
return points, nil
}
+13
View File
@@ -0,0 +1,13 @@
package kql_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestGeoKQL(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "KQL Geo Suite")
}
+85
View File
@@ -0,0 +1,85 @@
package kql_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/opencloud/pkg/ast"
"github.com/opencloud-eu/opencloud/pkg/kql"
)
// firstNode parses q and returns its single restriction node.
func firstNode(q string) (ast.Node, error) {
a, err := kql.Builder{}.Build(q)
if err != nil {
return nil, err
}
Expect(a.Nodes).To(HaveLen(1))
return a.Nodes[0], nil
}
var _ = Describe("geo KQL predicates", func() {
It("parses geo.distance into a GeoDistanceNode", func() {
n, err := firstNode("location:geo.distance(48.2082, 16.3738, 5km)")
Expect(err).ToNot(HaveOccurred())
Expect(n).To(BeAssignableToTypeOf(&ast.GeoDistanceNode{}))
d := n.(*ast.GeoDistanceNode)
Expect(d.Key).To(Equal("location"))
Expect(d.Lat).To(Equal(48.2082))
Expect(d.Lon).To(Equal(16.3738))
Expect(d.Radius).To(Equal(5000.0))
})
DescribeTable("normalises the radius unit to meters",
func(radius string, meters float64) {
n, err := firstNode("location:geo.distance(1, 2, " + radius + ")")
Expect(err).ToNot(HaveOccurred())
Expect(n.(*ast.GeoDistanceNode).Radius).To(Equal(meters))
},
Entry("kilometers", "5km", 5000.0),
Entry("meters", "500m", 500.0),
Entry("miles", "3mi", 3*1609.344),
Entry("fractional km", "1.5km", 1500.0),
)
It("parses geo.bbox into a GeoBoundingBoxNode", func() {
n, err := firstNode("location:geo.bbox(47.9, 16.1, 48.3, 16.5)")
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(&ast.GeoBoundingBoxNode{
Base: n.(*ast.GeoBoundingBoxNode).Base,
Key: "location",
MinLat: 47.9, MinLon: 16.1, MaxLat: 48.3, MaxLon: 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())
Expect(n).To(BeAssignableToTypeOf(&ast.GeoPolygonNode{}))
p := n.(*ast.GeoPolygonNode)
Expect(p.Key).To(Equal("location"))
Expect(p.Points).To(Equal([]ast.GeoPoint{
{Lat: 48.3, Lon: 16.1}, {Lat: 48.3, Lon: 16.5}, {Lat: 47.9, Lon: 16.5},
}))
})
It("composes a geo predicate with a plain restriction", func() {
a, err := kql.Builder{}.Build("mediatype:audio AND location:geo.bbox(47.9, 16.1, 48.3, 16.5)")
Expect(err).ToNot(HaveOccurred())
Expect(a.Nodes).To(HaveLen(3)) // string, operator, geo
Expect(a.Nodes[2]).To(BeAssignableToTypeOf(&ast.GeoBoundingBoxNode{}))
})
DescribeTable("rejects malformed geo predicates",
func(q string) {
_, err := kql.Builder{}.Build(q)
Expect(err).To(HaveOccurred())
},
Entry("distance without a unit", "location:geo.distance(1, 2, 5)"),
Entry("distance with too few args", "location:geo.distance(1, 2)"),
Entry("distance with a non-numeric coord", "location:geo.distance(x, 2, 5km)"),
Entry("bbox with too few args", "location:geo.bbox(1, 2, 3)"),
Entry("polygon with fewer than three points", "location:geo.polygon(1 2, 3 4)"),
Entry("polygon point without a lon", "location:geo.polygon(1, 2, 3)"),
)
})