diff --git a/services/search/pkg/bleve/geo_kql_test.go b/services/search/pkg/bleve/geo_kql_test.go index ce9ee14a1a..09686ef7b4 100644 --- a/services/search/pkg/bleve/geo_kql_test.go +++ b/services/search/pkg/bleve/geo_kql_test.go @@ -45,4 +45,9 @@ var _ = Describe("Geo KQL predicates", func() { Expect(hits(near + " AND location:geo.bbox(49.0, 11.0, 50.0, 12.0)")).To(Equal(1)) Expect(hits(near + " AND location:geo.bbox(52.0, 13.0, 53.0, 14.0)")).To(Equal(0)) }) + + 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()) + }) }) diff --git a/services/search/pkg/opensearch/internal/convert/kql_transpile.go b/services/search/pkg/opensearch/internal/convert/kql_transpile.go index 9c6eb1899d..42cad00530 100644 --- a/services/search/pkg/opensearch/internal/convert/kql_transpile.go +++ b/services/search/pkg/opensearch/internal/convert/kql_transpile.go @@ -15,10 +15,14 @@ import ( "github.com/opencloud-eu/opencloud/services/search/pkg/query" ) -// geoField maps a KQL geo key to its indexed geopoint sibling, e.g. -// "location" to "location_geopoint". -func geoField(key string) string { - return strings.ToLower(key) + mapping.GeopointSuffix +// geoQueryField resolves a KQL geo key to its indexed geopoint field, erroring +// when the key is not a geopoint field. +func geoQueryField(key string) (string, error) { + field, ok := query.ResolveGeoField(key) + if !ok { + return "", fmt.Errorf("geo predicate on non-geo field %q", key) + } + return field, nil } func TranspileKQLToOpenSearch(nodes []ast.Node) (osu.Builder, error) { @@ -193,14 +197,26 @@ func (t kqlOpensearchTranspiler) toBuilder(node ast.Node) (osu.Builder, error) { return group, nil case *ast.GeoDistanceNode: - return osu.NewGeoDistanceQuery(geoField(node.Key)). + field, err := geoQueryField(node.Key) + if err != nil { + return nil, err + } + return osu.NewGeoDistanceQuery(field). Distance(strconv.FormatFloat(node.Radius, 'f', -1, 64)+"m"). Point(node.Lat, node.Lon), nil case *ast.GeoBoundingBoxNode: - return osu.NewGeoBoundingBoxQuery(geoField(node.Key)). + field, err := geoQueryField(node.Key) + if err != nil { + return nil, err + } + return osu.NewGeoBoundingBoxQuery(field). Box(node.MinLat, node.MinLon, node.MaxLat, node.MaxLon), nil case *ast.GeoPolygonNode: - q := osu.NewGeoPolygonQuery(geoField(node.Key)) + field, err := geoQueryField(node.Key) + if err != nil { + return nil, err + } + q := osu.NewGeoPolygonQuery(field) for _, p := range node.Points { q.Point(p.Lat, p.Lon) } diff --git a/services/search/pkg/opensearch/internal/convert/kql_transpile_test.go b/services/search/pkg/opensearch/internal/convert/kql_transpile_test.go index ac822b6fb2..ea895680ee 100644 --- a/services/search/pkg/opensearch/internal/convert/kql_transpile_test.go +++ b/services/search/pkg/opensearch/internal/convert/kql_transpile_test.go @@ -473,3 +473,10 @@ func TestTranspileKQLToOpenSearch(t *testing.T) { }) } } + +func TestTranspileRejectsGeoOnNonGeoField(t *testing.T) { + _, err := convert.TranspileKQLToOpenSearch([]ast.Node{ + &ast.GeoDistanceNode{Key: "name", Lat: 48.2, Lon: 16.3, Radius: 5000}, + }) + assert.Error(t, err) +} diff --git a/services/search/pkg/query/bleve/compiler.go b/services/search/pkg/query/bleve/compiler.go index 0c2229330e..1319a6170f 100644 --- a/services/search/pkg/query/bleve/compiler.go +++ b/services/search/pkg/query/bleve/compiler.go @@ -42,10 +42,14 @@ var bleveEscaper = strings.NewReplacer( ` `, `\ `, ) -// geoField maps a KQL geo key to its indexed geopoint sibling, e.g. -// "location" to "location_geopoint". -func geoField(key string) string { - return strings.ToLower(key) + mapping.GeopointSuffix +// geoQueryField resolves a KQL geo key to its indexed geopoint field, erroring +// when the key is not a geopoint field. +func geoQueryField(key string) (string, error) { + field, ok := searchQuery.ResolveGeoField(key) + if !ok { + return "", fmt.Errorf("geo predicate on non-geo field %q", key) + } + return field, nil } // Compiler represents a KQL query search string to the bleve query formatter. @@ -237,29 +241,41 @@ func walk(offset int, nodes []ast.Node) (bleveQuery.Query, int, error) { next = q } case *ast.GeoDistanceNode: + field, err := geoQueryField(n.Key) + if err != nil { + return nil, 0, err + } q := bleveQuery.NewGeoDistanceQuery(n.Lon, n.Lat, strconv.FormatFloat(n.Radius, 'f', -1, 64)+"m") - q.SetField(geoField(n.Key)) + q.SetField(field) if prev == nil { prev = q } else { next = q } case *ast.GeoBoundingBoxNode: + field, err := geoQueryField(n.Key) + if err != nil { + return nil, 0, err + } // bleve takes the top-left and bottom-right corners. q := bleveQuery.NewGeoBoundingBoxQuery(n.MinLon, n.MaxLat, n.MaxLon, n.MinLat) - q.SetField(geoField(n.Key)) + q.SetField(field) if prev == nil { prev = q } else { next = q } case *ast.GeoPolygonNode: + field, err := geoQueryField(n.Key) + if err != nil { + return nil, 0, err + } points := make([]geo.Point, 0, len(n.Points)) for _, p := range n.Points { points = append(points, geo.Point{Lon: p.Lon, Lat: p.Lat}) } q := bleveQuery.NewGeoBoundingPolygonQuery(points) - q.SetField(geoField(n.Key)) + q.SetField(field) if prev == nil { prev = q } else { diff --git a/services/search/pkg/query/resolver.go b/services/search/pkg/query/resolver.go index 5628dd886f..b5eafe51c6 100644 --- a/services/search/pkg/query/resolver.go +++ b/services/search/pkg/query/resolver.go @@ -102,3 +102,24 @@ func FieldIsFulltext(field string) bool { func FieldIsWordBroken(field string) bool { return siblingFields()[field].Words } + +// geopointFields maps a lowercased KQL key to its indexed geopoint sibling field, +// derived from the TypeGeopoint entries in the resource field overrides (e.g. +// "location" -> "location_geopoint", "journey.start" -> "journey.start_geopoint"). +var geopointFields = sync.OnceValue(func() map[string]string { + out := map[string]string{} + for key, opts := range (search.Resource{}).SearchFieldOverrides() { + if opts.Type == mapping.TypeGeopoint { + out[strings.ToLower(key)] = key + mapping.GeopointSuffix + } + } + return out +}) + +// ResolveGeoField maps a KQL key to its indexed geopoint field name. ok is false +// when the key is not a geopoint field, so callers can reject geo predicates on +// non-geo fields. +func ResolveGeoField(name string) (string, bool) { + f, ok := geopointFields()[strings.ToLower(name)] + return f, ok +}