mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-11-09 05:52:52 -05:00
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:
1 parent
ddd0eda688
commit
b3cbf84b01
7 files changed
+1407
-507
No files matched your search
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)"),
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user