From 71b4f95172378609ca6cef53a2c655d9f92d239c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 3 Sep 2026 02:06:30 +0200 Subject: [PATCH] test(search): pin the geo predicates in the parity suite --- pkg/kql/error.go | 14 ++++++- .../internal/convert/kql_transpile.go | 2 +- services/search/pkg/parity/README.md | 20 ++++++++++ services/search/pkg/parity/parity_test.go | 1 + services/search/pkg/parity/query_geo_test.go | 40 +++++++++++++++++++ services/search/pkg/query/bleve/compiler.go | 2 +- 6 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 services/search/pkg/parity/query_geo_test.go diff --git a/pkg/kql/error.go b/pkg/kql/error.go index 47009001b3..7096a1afc5 100644 --- a/pkg/kql/error.go +++ b/pkg/kql/error.go @@ -39,6 +39,16 @@ func (e UnsupportedTimeRangeError) Error() string { return fmt.Sprintf("unable to convert '%v' to a time range", e.Value) } +// GeoFieldError records a geo predicate that was used on a field that is not +// indexed as a geopoint. +type GeoFieldError struct { + Key string +} + +func (e GeoFieldError) Error() string { + return fmt.Sprintf("geo predicate on non-geo field %q", e.Key) +} + // IsValidationError reports whether err is one of the KQL parse/validation // errors produced by this package, i.e. the query itself is at fault and the // caller should treat it as a bad request. @@ -47,9 +57,11 @@ func IsValidationError(err error) bool { startsWithBinaryOperator *StartsWithBinaryOperatorError namedGroupInvalidNodes *NamedGroupInvalidNodesError unsupportedTimeRange *UnsupportedTimeRangeError + geoField *GeoFieldError ) return errors.As(err, &startsWithBinaryOperator) || errors.As(err, &namedGroupInvalidNodes) || - errors.As(err, &unsupportedTimeRange) + errors.As(err, &unsupportedTimeRange) || + errors.As(err, &geoField) } diff --git a/services/search/pkg/opensearch/internal/convert/kql_transpile.go b/services/search/pkg/opensearch/internal/convert/kql_transpile.go index 42cad00530..3610d01a05 100644 --- a/services/search/pkg/opensearch/internal/convert/kql_transpile.go +++ b/services/search/pkg/opensearch/internal/convert/kql_transpile.go @@ -20,7 +20,7 @@ import ( 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 "", &kql.GeoFieldError{Key: key} } return field, nil } diff --git a/services/search/pkg/parity/README.md b/services/search/pkg/parity/README.md index 8b3691c710..82c77585a1 100644 --- a/services/search/pkg/parity/README.md +++ b/services/search/pkg/parity/README.md @@ -418,6 +418,26 @@ Fixtures: | RANGE-07 | `mtime>2021` | no match | no match | no match | ✅ | | RANGE-08 | `name>100` | no match | no match | no match | ✅ | +### geo + +Fixtures: + +- `vienna.txt` +- `bruck.txt` +- `berlin.txt` +- `nowhere.txt` + +| Case | Query | expected | bleve | OpenSearch | same? | +|---|---|---|---|---|---| +| GEO-01 | `location:geo.distance(48.2082, 16.3738, 5km)` | vienna.txt | vienna.txt | vienna.txt | ✅ | +| GEO-02 | `location:geo.distance(48.2082, 16.3738, 50km)` | bruck.txt, vienna.txt | bruck.txt, vienna.txt | bruck.txt, vienna.txt | ✅ | +| GEO-03 | `location:geo.distance(48.2082, 16.3738, 1m)` | vienna.txt | vienna.txt | vienna.txt | ✅ | +| GEO-04 | `location:geo.bbox(47.9, 16.1, 48.3, 16.5)` | vienna.txt | vienna.txt | vienna.txt | ✅ | +| GEO-05 | `location:geo.bbox(48.3, 16.1, 47.9, 16.5)` | vienna.txt | vienna.txt | vienna.txt | ✅ | +| GEO-06 | `location:geo.polygon(48.3 16.1, 48.3 16.5, 47.9 16.5, 47.9 16.1)` | vienna.txt | vienna.txt | vienna.txt | ✅ | +| GEO-07 | `location:geo.distance(0, 0, 20000km)` | berlin.txt, bruck.txt, vienna.txt | berlin.txt, bruck.txt, vienna.txt | berlin.txt, bruck.txt, vienna.txt | ✅ | +| GEO-08 | `name:geo.distance(48.2082, 16.3738, 5km)` | bad request | bad request | bad request | ✅ | + ### scope Fixtures: diff --git a/services/search/pkg/parity/parity_test.go b/services/search/pkg/parity/parity_test.go index 41695d18b0..60f0db8944 100644 --- a/services/search/pkg/parity/parity_test.go +++ b/services/search/pkg/parity/parity_test.go @@ -109,6 +109,7 @@ func queryGroups() []queryGroup { stressGroup(), everythingGroup(), rangeGroup(), + geoGroup(), scopeGroup(), invalidGroup(), } diff --git a/services/search/pkg/parity/query_geo_test.go b/services/search/pkg/parity/query_geo_test.go new file mode 100644 index 0000000000..f7be5d962f --- /dev/null +++ b/services/search/pkg/parity/query_geo_test.go @@ -0,0 +1,40 @@ +package parity + +import ( + libregraph "github.com/opencloud-eu/libre-graph-api-go" + + "github.com/opencloud-eu/opencloud/services/search/pkg/search" +) + +func at(lat, lon float64) fixtureOption { + return withLocation(&libregraph.GeoCoordinates{ + Latitude: libregraph.PtrFloat64(lat), + Longitude: libregraph.PtrFloat64(lon), + }) +} + +func geoGroup() queryGroup { + return queryGroup{ + name: "geo", + fixtures: []search.Resource{ + // vienna, and a second point ~35km east of it + fixtureDoc("vienna.txt", at(48.2082, 16.3738)), + fixtureDoc("bruck.txt", at(48.2082, 16.8500)), + fixtureDoc("berlin.txt", at(52.5200, 13.4050)), + fixtureDoc("nowhere.txt"), + }, + cases: []queryCase{ + {id: 1, query: `location:geo.distance(48.2082, 16.3738, 5km)`, want: []string{"vienna.txt"}}, + {id: 2, query: `location:geo.distance(48.2082, 16.3738, 50km)`, want: []string{"vienna.txt", "bruck.txt"}}, + {id: 3, query: `location:geo.distance(48.2082, 16.3738, 1m)`, want: []string{"vienna.txt"}}, + {id: 4, query: `location:geo.bbox(47.9, 16.1, 48.3, 16.5)`, want: []string{"vienna.txt"}}, + // the corners may come in either order, both name the same box + {id: 5, query: `location:geo.bbox(48.3, 16.1, 47.9, 16.5)`, want: []string{"vienna.txt"}}, + {id: 6, query: `location:geo.polygon(48.3 16.1, 48.3 16.5, 47.9 16.5, 47.9 16.1)`, want: []string{"vienna.txt"}}, + // a document without a location is never a geo hit + {id: 7, query: `location:geo.distance(0, 0, 20000km)`, want: []string{"vienna.txt", "bruck.txt", "berlin.txt"}}, + // geo predicates only apply to geopoint fields, both engines reject the rest + {id: 8, query: `name:geo.distance(48.2082, 16.3738, 5km)`, wantBadRequest: true}, + }, + } +} diff --git a/services/search/pkg/query/bleve/compiler.go b/services/search/pkg/query/bleve/compiler.go index 1319a6170f..a028828690 100644 --- a/services/search/pkg/query/bleve/compiler.go +++ b/services/search/pkg/query/bleve/compiler.go @@ -47,7 +47,7 @@ var bleveEscaper = strings.NewReplacer( 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 "", &kql.GeoFieldError{Key: key} } return field, nil }