mirror of
https://gitee.com/milvus-io/milvus.git
synced 2025-12-06 17:18:35 +08:00
issue: #43427 This pr's main goal is merge #37417 to milvus 2.5 without conflicts. # Main Goals 1. Create and describe collections with geospatial type 2. Insert geospatial data into the insert binlog 3. Load segments containing geospatial data into memory 4. Enable query and search can display geospatial data 5. Support using GIS funtions like ST_EQUALS in query 6. Support R-Tree index for geometry type # Solution 1. **Add Type**: Modify the Milvus core by adding a Geospatial type in both the C++ and Go code layers, defining the Geospatial data structure and the corresponding interfaces. 2. **Dependency Libraries**: Introduce necessary geospatial data processing libraries. In the C++ source code, use Conan package management to include the GDAL library. In the Go source code, add the go-geom library to the go.mod file. 3. **Protocol Interface**: Revise the Milvus protocol to provide mechanisms for Geospatial message serialization and deserialization. 4. **Data Pipeline**: Facilitate interaction between the client and proxy using the WKT format for geospatial data. The proxy will convert all data into WKB format for downstream processing, providing column data interfaces, segment encapsulation, segment loading, payload writing, and cache block management. 5. **Query Operators**: Implement simple display and support for filter queries. Initially, focus on filtering based on spatial relationships for a single column of geospatial literal values, providing parsing and execution for query expressions.Now only support brutal search 7. **Client Modification**: Enable the client to handle user input for geospatial data and facilitate end-to-end testing.Check the modification in pymilvus. --------- Signed-off-by: Yinwei Li <yinwei.li@zilliz.com> Signed-off-by: Cai Zhang <cai.zhang@zilliz.com> Co-authored-by: ZhuXi <150327960+Yinwei-Yu@users.noreply.github.com>
816 lines
28 KiB
Go
816 lines
28 KiB
Go
package testcases
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
// Import OGC-compliant geometry library to provide standard spatial relation predicates
|
|
sgeom "github.com/peterstace/simplefeatures/geom"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/twpayne/go-geom"
|
|
"github.com/twpayne/go-geom/encoding/wkt"
|
|
|
|
"github.com/milvus-io/milvus/client/v2/column"
|
|
"github.com/milvus-io/milvus/client/v2/entity"
|
|
"github.com/milvus-io/milvus/client/v2/index"
|
|
client "github.com/milvus-io/milvus/client/v2/milvusclient"
|
|
base "github.com/milvus-io/milvus/tests/go_client/base"
|
|
"github.com/milvus-io/milvus/tests/go_client/common"
|
|
hp "github.com/milvus-io/milvus/tests/go_client/testcases/helper"
|
|
)
|
|
|
|
// GeometryTestData contains test data and expected relations
|
|
type GeometryTestData struct {
|
|
IDs []int64
|
|
Geometries []string
|
|
Vectors [][]float32
|
|
ExpectedRelations map[string][]int64 // Key is spatial function name, value is list of IDs that match the relation
|
|
}
|
|
|
|
// TestSetup contains objects after test initialization
|
|
type TestSetup struct {
|
|
Ctx context.Context
|
|
Client *base.MilvusClient
|
|
Prepare *hp.CollectionPrepare
|
|
Schema *entity.Schema
|
|
Collection string
|
|
}
|
|
|
|
// setupGeometryTest is a unified helper function for test setup
|
|
// withVectorIndex: whether to create vector index
|
|
// withSpatialIndex: whether to create spatial index
|
|
// customData: optional custom test data
|
|
func setupGeometryTest(t *testing.T, withVectorIndex bool, withSpatialIndex bool, customData *GeometryTestData) *TestSetup {
|
|
ctx := hp.CreateContext(t, time.Second*common.DefaultTimeout)
|
|
mc := hp.CreateDefaultMilvusClient(ctx, t)
|
|
|
|
// Create collection
|
|
// Use default vector dimension for default data, 8 dimensions for custom data
|
|
dim := int64(8)
|
|
if customData == nil {
|
|
dim = int64(common.DefaultDim)
|
|
}
|
|
prepare, schema := hp.CollPrepare.CreateCollection(ctx, t, mc,
|
|
hp.NewCreateCollectionParams(hp.Int64VecGeometry),
|
|
hp.TNewFieldsOption().TWithDim(dim),
|
|
hp.TNewSchemaOption())
|
|
|
|
// Insert data
|
|
if customData != nil {
|
|
// Use custom data
|
|
pkColumn := column.NewColumnInt64(common.DefaultInt64FieldName, customData.IDs)
|
|
vecColumn := column.NewColumnFloatVector(common.DefaultFloatVecFieldName, 8, customData.Vectors)
|
|
geoColumn := column.NewColumnGeometryWKT(common.DefaultGeometryFieldName, customData.Geometries)
|
|
|
|
_, err := mc.Insert(ctx, client.NewColumnBasedInsertOption(schema.CollectionName, pkColumn, vecColumn, geoColumn))
|
|
common.CheckErr(t, err, true)
|
|
} else {
|
|
// Use default data
|
|
prepare.InsertData(ctx, t, mc,
|
|
hp.NewInsertParams(schema),
|
|
hp.TNewDataOption())
|
|
}
|
|
|
|
// Flush data
|
|
prepare.FlushData(ctx, t, mc, schema.CollectionName)
|
|
|
|
// Create index based on parameters
|
|
if withVectorIndex {
|
|
prepare.CreateIndex(ctx, t, mc, hp.TNewIndexParams(schema))
|
|
}
|
|
|
|
if withSpatialIndex {
|
|
rtreeIndex := index.NewRTreeIndex()
|
|
_, err := mc.CreateIndex(ctx, client.NewCreateIndexOption(
|
|
schema.CollectionName,
|
|
common.DefaultGeometryFieldName,
|
|
rtreeIndex))
|
|
common.CheckErr(t, err, true)
|
|
}
|
|
|
|
// Load collection
|
|
prepare.Load(ctx, t, mc, hp.NewLoadParams(schema.CollectionName))
|
|
|
|
return &TestSetup{
|
|
Ctx: ctx,
|
|
Client: mc,
|
|
Prepare: prepare,
|
|
Schema: schema,
|
|
Collection: schema.CollectionName,
|
|
}
|
|
}
|
|
|
|
// createEnhancedSpatialTestData creates enhanced test data containing all six Geometry types
|
|
// Returns test data and expected spatial relation mappings
|
|
func createEnhancedSpatialTestData() *GeometryTestData {
|
|
// Define test data: supports all six Geometry types
|
|
pks := []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
|
|
|
|
// Generate vector data for each ID
|
|
vecs := make([][]float32, len(pks))
|
|
for i := range pks {
|
|
vecs[i] = []float32{
|
|
float32(i + 1), float32(i + 2), float32(i + 3), float32(i + 4),
|
|
float32(i + 5), float32(i + 6), float32(i + 7), float32(i + 8),
|
|
}
|
|
}
|
|
|
|
// Carefully designed geometry data covering all six types and various spatial relations
|
|
geometries := []string{
|
|
// Points - Test various relations between points and query polygons
|
|
"POINT (5 5)", // ID=1: Completely inside the query polygon
|
|
"POINT (0 0)", // ID=2: On the vertex (boundary) of the query polygon
|
|
"POINT (10 10)", // ID=3: On the vertex (boundary) of the query polygon
|
|
"POINT (15 15)", // ID=4: Completely outside the query polygon
|
|
"POINT (-5 -5)", // ID=5: Completely outside the query polygon
|
|
|
|
// LineStrings - Test various relations between lines and query polygons
|
|
"LINESTRING (0 0, 15 15)", // ID=6: Passes through the query polygon (intersects but not contains)
|
|
"LINESTRING (5 0, 5 15)", // ID=7: Intersects with the query polygon
|
|
"LINESTRING (2 2, 8 8)", // ID=8: Completely inside the query polygon
|
|
"LINESTRING (12 12, 18 18)", // ID=9: Completely outside the query polygon
|
|
|
|
// Polygons - Test various relations between polygons and query polygons
|
|
"POLYGON ((8 8, 15 8, 15 15, 8 15, 8 8))", // ID=10: Partially overlaps
|
|
"POLYGON ((2 2, 8 2, 8 8, 2 8, 2 2))", // ID=11: Completely contained inside
|
|
"POLYGON ((12 12, 18 12, 18 18, 12 18, 12 12))", // ID=12: Completely outside
|
|
|
|
// MultiPoints - Test multipoint geometries
|
|
"MULTIPOINT ((3 3), (7 7))", // ID=13: All points inside
|
|
"MULTIPOINT ((0 0), (15 15))", // ID=14: Points on the boundary
|
|
|
|
// MultiLineStrings - Test multiline geometries
|
|
"MULTILINESTRING ((1 1, 3 3), (7 7, 9 9))", // ID=15: Multiple line segments all inside
|
|
}
|
|
|
|
// Define query polygon for calculating expected relations
|
|
queryPolygon := "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" // 10x10 square
|
|
|
|
// Calculate expected spatial relations using a third-party library
|
|
expectedRelations := calculateExpectedRelations(geometries, queryPolygon, pks)
|
|
|
|
return &GeometryTestData{
|
|
IDs: pks,
|
|
Geometries: geometries,
|
|
Vectors: vecs,
|
|
ExpectedRelations: expectedRelations,
|
|
}
|
|
}
|
|
|
|
// calculateExpectedRelations calculates expected spatial relations using a third-party library
|
|
// This provides a "standard answer" to verify the correctness of Milvus query results
|
|
func calculateExpectedRelations(geometries []string, queryWKT string, ids []int64) map[string][]int64 {
|
|
// Parse query polygon
|
|
// Use WKT to parse into a third-party geometry for internal conversion by the wrapper function
|
|
queryGeom, err := wkt.Unmarshal(queryWKT)
|
|
if err != nil {
|
|
return make(map[string][]int64)
|
|
}
|
|
|
|
relations := map[string][]int64{
|
|
"ST_INTERSECTS": {},
|
|
"ST_WITHIN": {},
|
|
"ST_CONTAINS": {},
|
|
"ST_EQUALS": {},
|
|
"ST_TOUCHES": {},
|
|
"ST_OVERLAPS": {},
|
|
"ST_CROSSES": {},
|
|
}
|
|
|
|
for i, geoWKT := range geometries {
|
|
// Parse current geometry object
|
|
geom, err := wkt.Unmarshal(geoWKT)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
id := ids[i]
|
|
|
|
// Calculate various spatial relations
|
|
// Note: go-geom library function names may differ slightly from PostGIS/OGC standards
|
|
// Here we perform logical judgments based on geometry type and spatial relations
|
|
|
|
// ST_INTERSECTS: Checks for intersection (including boundary contact)
|
|
if intersects := checkIntersects(geom, queryGeom); intersects {
|
|
relations["ST_INTERSECTS"] = append(relations["ST_INTERSECTS"], id)
|
|
}
|
|
|
|
// ST_WITHIN: Checks if completely contained inside (excluding boundaries)
|
|
// Important note: ST_WITHIN according to OGC standard, does not include boundary points
|
|
// That is, if a point is on the boundary of a polygon, ST_WITHIN should return false
|
|
// This is an important semantic difference, and our test cases specifically verify this behavior
|
|
if within := checkWithin(geom, queryGeom); within {
|
|
relations["ST_WITHIN"] = append(relations["ST_WITHIN"], id)
|
|
}
|
|
|
|
// ST_CONTAINS: Checks if query geometry contains target geometry
|
|
if contains := checkContains(geom, queryGeom); contains {
|
|
relations["ST_CONTAINS"] = append(relations["ST_CONTAINS"], id)
|
|
}
|
|
|
|
// ST_EQUALS: Checks for exact equality
|
|
if equals := checkEquals(geom, queryGeom); equals {
|
|
relations["ST_EQUALS"] = append(relations["ST_EQUALS"], id)
|
|
}
|
|
|
|
// ST_TOUCHES: Checks if only touching at the boundary
|
|
if touches := checkTouches(geom, queryGeom); touches {
|
|
relations["ST_TOUCHES"] = append(relations["ST_TOUCHES"], id)
|
|
}
|
|
|
|
// ST_OVERLAPS: Checks for partial overlap
|
|
if overlaps := checkOverlaps(geom, queryGeom); overlaps {
|
|
relations["ST_OVERLAPS"] = append(relations["ST_OVERLAPS"], id)
|
|
}
|
|
|
|
// ST_CROSSES: Checks for crossing
|
|
if crosses := checkCrosses(geom, queryGeom); crosses {
|
|
relations["ST_CROSSES"] = append(relations["ST_CROSSES"], id)
|
|
}
|
|
}
|
|
|
|
return relations
|
|
}
|
|
|
|
// The following functions implement spatial relation checks using the go-geom library
|
|
// These functions provide "standard answers" to verify Milvus query results
|
|
|
|
func checkIntersects(g1, g2 geom.T) bool {
|
|
lhs, err1 := sgeom.UnmarshalWKT(extractWKT(g1))
|
|
rhs, err2 := sgeom.UnmarshalWKT(extractWKT(g2))
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
return sgeom.Intersects(lhs, rhs)
|
|
}
|
|
|
|
func checkWithin(g1, g2 geom.T) bool {
|
|
lhs, err1 := sgeom.UnmarshalWKT(extractWKT(g1))
|
|
rhs, err2 := sgeom.UnmarshalWKT(extractWKT(g2))
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
ok, _ := sgeom.Within(lhs, rhs)
|
|
return ok
|
|
}
|
|
|
|
func checkContains(g1, g2 geom.T) bool {
|
|
lhs, err1 := sgeom.UnmarshalWKT(extractWKT(g1))
|
|
rhs, err2 := sgeom.UnmarshalWKT(extractWKT(g2))
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
ok, _ := sgeom.Contains(lhs, rhs)
|
|
return ok
|
|
}
|
|
|
|
func checkEquals(g1, g2 geom.T) bool {
|
|
lhs, err1 := sgeom.UnmarshalWKT(extractWKT(g1))
|
|
rhs, err2 := sgeom.UnmarshalWKT(extractWKT(g2))
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
ok, _ := sgeom.Equals(lhs, rhs)
|
|
return ok
|
|
}
|
|
|
|
func checkTouches(g1, g2 geom.T) bool {
|
|
lhs, err1 := sgeom.UnmarshalWKT(extractWKT(g1))
|
|
rhs, err2 := sgeom.UnmarshalWKT(extractWKT(g2))
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
ok, _ := sgeom.Touches(lhs, rhs)
|
|
return ok
|
|
}
|
|
|
|
func checkOverlaps(g1, g2 geom.T) bool {
|
|
lhs, err1 := sgeom.UnmarshalWKT(extractWKT(g1))
|
|
rhs, err2 := sgeom.UnmarshalWKT(extractWKT(g2))
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
ok, _ := sgeom.Overlaps(lhs, rhs)
|
|
return ok
|
|
}
|
|
|
|
func checkCrosses(g1, g2 geom.T) bool {
|
|
lhs, err1 := sgeom.UnmarshalWKT(extractWKT(g1))
|
|
rhs, err2 := sgeom.UnmarshalWKT(extractWKT(g2))
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
ok, _ := sgeom.Crosses(lhs, rhs)
|
|
return ok
|
|
}
|
|
|
|
// Helper functions
|
|
func extractCoordinates(g geom.T) []float64 {
|
|
switch g := g.(type) {
|
|
case *geom.Point:
|
|
return g.Coords()
|
|
case *geom.LineString:
|
|
if g.NumCoords() > 0 {
|
|
return g.Coord(0)
|
|
}
|
|
case *geom.Polygon:
|
|
if g.NumLinearRings() > 0 && g.LinearRing(0).NumCoords() > 0 {
|
|
return g.LinearRing(0).Coord(0)
|
|
}
|
|
}
|
|
return []float64{}
|
|
}
|
|
|
|
func extractWKT(geom geom.T) string {
|
|
wktStr, _ := wkt.Marshal(geom)
|
|
return wktStr
|
|
}
|
|
|
|
// getQueryPolygon returns the query polygon used for testing
|
|
func getQueryPolygon() string {
|
|
return "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))" // 10x10 square
|
|
}
|
|
|
|
// logTestResult records test results for debugging
|
|
func logTestResult(t *testing.T, testName string, expected, actual int, details string) {
|
|
t.Helper()
|
|
if expected != actual {
|
|
t.Errorf("[%s] Expected: %d, Actual: %d. %s", testName, expected, actual, details)
|
|
}
|
|
}
|
|
|
|
// validateSpatialResults validates the correctness of spatial query results using a third-party library
|
|
func validateSpatialResults(t *testing.T, actualIDs []int64, expectedIDs []int64, testName string) {
|
|
t.Helper()
|
|
// Convert slice to map for quick lookup
|
|
expectedMap := make(map[int64]bool)
|
|
for _, id := range expectedIDs {
|
|
expectedMap[id] = true
|
|
}
|
|
|
|
actualMap := make(map[int64]bool)
|
|
for _, id := range actualIDs {
|
|
actualMap[id] = true
|
|
}
|
|
|
|
// Unexpected results should not occur
|
|
for _, actualID := range actualIDs {
|
|
if !expectedMap[actualID] {
|
|
t.Errorf("[%s] Unexpected ID in result: %d", testName, actualID)
|
|
}
|
|
}
|
|
|
|
// Missing expected results should not occur
|
|
for _, expectedID := range expectedIDs {
|
|
if !actualMap[expectedID] {
|
|
t.Errorf("[%s] Missing expected ID: %d", testName, expectedID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Basic Function Verification: Create collection, insert data, get data by primary key
|
|
func TestGeometryBasicCRUD(t *testing.T) {
|
|
// Use unified test setup function
|
|
setup := setupGeometryTest(t, true, false, nil)
|
|
defer func() {}()
|
|
|
|
// Get data by primary key and verify geometry field
|
|
getAllResult, errGet := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).
|
|
WithFilter(fmt.Sprintf("%s >= 0", common.DefaultInt64FieldName)).
|
|
WithLimit(10).
|
|
WithOutputFields(common.DefaultInt64FieldName, common.DefaultGeometryFieldName))
|
|
require.NoError(t, errGet)
|
|
|
|
// Verify returned data
|
|
require.Equal(t, 10, getAllResult.ResultCount, "Query operation should return 10 records")
|
|
require.Equal(t, 2, len(getAllResult.Fields), "Should return 2 fields (ID and Geometry)")
|
|
|
|
// Verify geometry field data integrity
|
|
geoColumn := getAllResult.GetColumn(common.DefaultGeometryFieldName)
|
|
require.Equal(t, 10, geoColumn.Len(), "Geometry field should have 10 data points")
|
|
}
|
|
|
|
// 2. Simple query operation without spatial index
|
|
func TestGeometryQueryWithoutRtreeIndex_Simple(t *testing.T) {
|
|
// Use unified setup, without creating spatial index
|
|
setup := setupGeometryTest(t, true, false, nil)
|
|
|
|
// Query the first geometry object (POINT (30.123 -10.456))
|
|
targetGeometry := "POINT (30.123 -10.456)"
|
|
expr := fmt.Sprintf("ST_EQUALS(%s, '%s')", common.DefaultGeometryFieldName, targetGeometry)
|
|
|
|
queryResult, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).
|
|
WithFilter(expr).
|
|
WithOutputFields(common.DefaultInt64FieldName, common.DefaultGeometryFieldName))
|
|
require.NoError(t, err)
|
|
|
|
// Verify results: In data generation function GenDefaultGeometryData, data loops every 6, the first one is POINT
|
|
expectedCount := common.DefaultNb / 6
|
|
actualCount := queryResult.ResultCount
|
|
|
|
require.Equal(t, expectedCount, actualCount, "Query result count should match expectation")
|
|
|
|
// Verify that the returned geometry data is indeed the target geometry
|
|
if actualCount > 0 {
|
|
geoColumn := queryResult.GetColumn(common.DefaultGeometryFieldName)
|
|
for i := 0; i < geoColumn.Len(); i++ {
|
|
geoData, _ := geoColumn.GetAsString(i)
|
|
require.Equal(t, targetGeometry, geoData, "Returned geometry data should match query condition")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Complex query operation without spatial index (using enhanced test data and third-party library verification)
|
|
func TestGeometryQueryWithoutRtreeIndex_Complex(t *testing.T) {
|
|
// Use enhanced test data
|
|
testData := createEnhancedSpatialTestData()
|
|
setup := setupGeometryTest(t, true, false, testData)
|
|
|
|
queryPolygon := getQueryPolygon()
|
|
|
|
// Use decoupled test case definition
|
|
testCases := []struct {
|
|
name string
|
|
expr string
|
|
description string
|
|
functionKey string // Key corresponding to ExpectedRelations
|
|
}{
|
|
{
|
|
name: "ST_Intersects Intersection Query",
|
|
expr: fmt.Sprintf("ST_INTERSECTS(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Find all geometries intersecting with the query polygon (including boundary contact)",
|
|
functionKey: "ST_INTERSECTS",
|
|
},
|
|
{
|
|
name: "ST_Within Contains Query",
|
|
expr: fmt.Sprintf("ST_WITHIN(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Find geometries completely contained within the query polygon (OGC standard: excluding boundary points)",
|
|
functionKey: "ST_WITHIN",
|
|
},
|
|
{
|
|
name: "ST_Contains Contains Relation Query",
|
|
expr: fmt.Sprintf("ST_CONTAINS(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Find geometries containing the query polygon",
|
|
functionKey: "ST_CONTAINS",
|
|
},
|
|
{
|
|
name: "ST_Equals Equality Query",
|
|
expr: fmt.Sprintf("ST_EQUALS(%s, 'POINT (5 5)')", common.DefaultGeometryFieldName),
|
|
description: "Find geometries exactly equal to the specified point",
|
|
functionKey: "ST_EQUALS",
|
|
},
|
|
{
|
|
name: "ST_Touches Tangent Query",
|
|
expr: fmt.Sprintf("ST_TOUCHES(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Find geometries touching the query polygon only at the boundary",
|
|
functionKey: "ST_TOUCHES",
|
|
},
|
|
{
|
|
name: "ST_Overlaps Overlap Query",
|
|
expr: fmt.Sprintf("ST_OVERLAPS(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Find geometries partially overlapping with the query polygon",
|
|
functionKey: "ST_OVERLAPS",
|
|
},
|
|
{
|
|
name: "ST_Crosses Crossing Query",
|
|
expr: fmt.Sprintf("ST_CROSSES(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Find geometries crossing the query polygon",
|
|
functionKey: "ST_CROSSES",
|
|
},
|
|
}
|
|
|
|
// Execute test cases
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
queryResult, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).
|
|
WithFilter(tc.expr).
|
|
WithOutputFields(common.DefaultInt64FieldName, common.DefaultGeometryFieldName))
|
|
require.NoError(t, err)
|
|
|
|
// Get expected results from the expected relations map
|
|
expectedIDs, exists := testData.ExpectedRelations[tc.functionKey]
|
|
if !exists {
|
|
expectedIDs = []int64{}
|
|
}
|
|
|
|
if tc.functionKey == "ST_EQUALS" {
|
|
expectedIDs = []int64{1}
|
|
}
|
|
|
|
actualCount := queryResult.ResultCount
|
|
|
|
// Extract actual IDs returned by the query
|
|
var actualIDs []int64
|
|
if actualCount > 0 {
|
|
idColumn := queryResult.GetColumn(common.DefaultInt64FieldName)
|
|
for i := 0; i < actualCount; i++ {
|
|
id, _ := idColumn.GetAsInt64(i)
|
|
actualIDs = append(actualIDs, id)
|
|
}
|
|
}
|
|
|
|
// Verify the correctness of results
|
|
validateSpatialResults(t, actualIDs, expectedIDs, tc.name)
|
|
|
|
// Loose validation
|
|
require.True(t, actualCount >= 0, "Query result count should be non-negative")
|
|
if len(expectedIDs) > 0 {
|
|
require.True(t, actualCount > 0, "When there are expected results, the actual query should return at least one record")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 4. Simple query operation with spatial index
|
|
func TestGeometryQueryWithRtreeIndex_Simple(t *testing.T) {
|
|
// Use unified setup, create spatial index
|
|
setup := setupGeometryTest(t, true, true, nil)
|
|
|
|
// Execute the same query as the no-index test
|
|
targetGeometry := "POINT (30.123 -10.456)"
|
|
expr := fmt.Sprintf("ST_EQUALS(%s, '%s')", common.DefaultGeometryFieldName, targetGeometry)
|
|
|
|
queryResult, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).
|
|
WithFilter(expr).
|
|
WithOutputFields(common.DefaultInt64FieldName, common.DefaultGeometryFieldName))
|
|
require.NoError(t, err)
|
|
|
|
// Verify results (should be the same as the no-index query results)
|
|
expectedCount := common.DefaultNb / 6
|
|
actualCount := queryResult.ResultCount
|
|
|
|
require.Equal(t, expectedCount, actualCount, "Indexed and non-indexed query results should be consistent")
|
|
}
|
|
|
|
// 5. Complex query operation with spatial index
|
|
func TestGeometryQueryWithRtreeIndex_Complex(t *testing.T) {
|
|
// Use enhanced test data and spatial index
|
|
testData := createEnhancedSpatialTestData()
|
|
setup := setupGeometryTest(t, true, true, testData)
|
|
|
|
queryPolygon := getQueryPolygon()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
expr string
|
|
description string
|
|
functionKey string
|
|
}{
|
|
{
|
|
name: "ST_Intersects Index Query",
|
|
expr: fmt.Sprintf("ST_INTERSECTS(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Intersection query using R-tree index",
|
|
functionKey: "ST_INTERSECTS",
|
|
},
|
|
{
|
|
name: "ST_Within Index Query",
|
|
expr: fmt.Sprintf("ST_WITHIN(%s, '%s')", common.DefaultGeometryFieldName, queryPolygon),
|
|
description: "Contains query using R-tree index",
|
|
functionKey: "ST_WITHIN",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
queryResult, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).
|
|
WithFilter(tc.expr).
|
|
WithOutputFields(common.DefaultInt64FieldName, common.DefaultGeometryFieldName))
|
|
require.NoError(t, err)
|
|
|
|
// Get expected results
|
|
expectedIDs := testData.ExpectedRelations[tc.functionKey]
|
|
actualCount := queryResult.ResultCount
|
|
|
|
// Extract actual IDs
|
|
var actualIDs []int64
|
|
if actualCount > 0 {
|
|
idColumn := queryResult.GetColumn(common.DefaultInt64FieldName)
|
|
for i := 0; i < actualCount; i++ {
|
|
id, _ := idColumn.GetAsInt64(i)
|
|
actualIDs = append(actualIDs, id)
|
|
}
|
|
}
|
|
|
|
// Verify results
|
|
validateSpatialResults(t, actualIDs, expectedIDs, tc.name)
|
|
require.True(t, queryResult.ResultCount >= 0, "Index query should execute successfully")
|
|
})
|
|
}
|
|
}
|
|
|
|
// 6. Enhanced Exception and Boundary Case Handling
|
|
func TestGeometryErrorHandling(t *testing.T) {
|
|
// Use enhanced test data
|
|
testData := createEnhancedSpatialTestData()
|
|
setup := setupGeometryTest(t, true, false, testData)
|
|
|
|
errorTestCases := []struct {
|
|
name string
|
|
testFunc func() error
|
|
expectedError bool
|
|
errorKeywords []string
|
|
description string
|
|
}{
|
|
{
|
|
name: "Invalid WKT format 1",
|
|
testFunc: func() error {
|
|
invalidGeometry := "INVALID_WKT_FORMAT"
|
|
expr := fmt.Sprintf("ST_EQUALS(%s, '%s')", common.DefaultGeometryFieldName, invalidGeometry)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: true,
|
|
errorKeywords: []string{"parse", "invalid", "wkt"},
|
|
description: "Using invalid WKT format should return parsing error",
|
|
},
|
|
{
|
|
name: "Invalid WKT format 2",
|
|
testFunc: func() error {
|
|
invalidGeometry := "POINT (INVALID COORDINATES)"
|
|
expr := fmt.Sprintf("ST_EQUALS(%s, '%s')", common.DefaultGeometryFieldName, invalidGeometry)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: true,
|
|
errorKeywords: []string{"parse", "invalid", "coordinate", "construct"},
|
|
description: "WKT with invalid coordinates should return parsing error",
|
|
},
|
|
{
|
|
name: "Incomplete Polygon",
|
|
testFunc: func() error {
|
|
invalidPolygon := "POLYGON ((0 0, 10 0, 10 10))" // Missing closing point
|
|
expr := fmt.Sprintf("ST_WITHIN(%s, '%s')", common.DefaultGeometryFieldName, invalidPolygon)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: true,
|
|
errorKeywords: []string{"polygon", "close", "ring", "invalid"},
|
|
description: "Incomplete polygon should return an error",
|
|
},
|
|
{
|
|
name: "Query with polygon with hole",
|
|
testFunc: func() error {
|
|
polygonWithHole := "POLYGON ((0 0, 20 0, 20 20, 0 20, 0 0), (5 5, 15 5, 15 15, 5 15, 5 5))"
|
|
expr := fmt.Sprintf("ST_WITHIN(%s, '%s')", common.DefaultGeometryFieldName, polygonWithHole)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: false,
|
|
errorKeywords: []string{},
|
|
description: "Polygon with hole should be handled correctly",
|
|
},
|
|
{
|
|
name: "Self-intersecting Polygon",
|
|
testFunc: func() error {
|
|
selfIntersectingPolygon := "POLYGON ((0 0, 10 10, 10 0, 0 10, 0 0))"
|
|
expr := fmt.Sprintf("ST_INTERSECTS(%s, '%s')", common.DefaultGeometryFieldName, selfIntersectingPolygon)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: false,
|
|
errorKeywords: []string{"invalid", "self", "intersect"},
|
|
description: "Self-intersecting polygon query should succeed with current implementation",
|
|
},
|
|
{
|
|
name: "Invalid spatial function",
|
|
testFunc: func() error {
|
|
expr := fmt.Sprintf("ST_NonExistentFunction(%s, 'POINT (0 0)')", common.DefaultGeometryFieldName)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: true,
|
|
errorKeywords: []string{"function", "undefined", "ST_NonExistentFunction"},
|
|
description: "Using non-existent spatial function should return an error",
|
|
},
|
|
{
|
|
name: "Incorrect number of spatial function parameters",
|
|
testFunc: func() error {
|
|
expr := fmt.Sprintf("ST_INTERSECTS(%s)", common.DefaultGeometryFieldName)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: true,
|
|
errorKeywords: []string{"parameter", "argument", "function"},
|
|
description: "Insufficient spatial function parameters should return an error",
|
|
},
|
|
{
|
|
name: "Extreme coordinate value test",
|
|
testFunc: func() error {
|
|
largeCoordinate := "POINT (179.9999 89.9999)"
|
|
expr := fmt.Sprintf("ST_EQUALS(%s, '%s')", common.DefaultGeometryFieldName, largeCoordinate)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: false,
|
|
errorKeywords: []string{},
|
|
description: "Extreme but valid coordinate values should be handled correctly",
|
|
},
|
|
{
|
|
name: "Invalid extreme coordinate value",
|
|
testFunc: func() error {
|
|
invalidLargeCoordinate := "POINT (1000000000 1000000000)"
|
|
expr := fmt.Sprintf("ST_EQUALS(%s, '%s')", common.DefaultGeometryFieldName, invalidLargeCoordinate)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
return err
|
|
},
|
|
expectedError: false,
|
|
errorKeywords: []string{},
|
|
description: "Query with extremely large coordinate values should execute but may yield no results",
|
|
},
|
|
}
|
|
|
|
for _, tc := range errorTestCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := tc.testFunc()
|
|
|
|
if tc.expectedError {
|
|
require.Error(t, err, "Should return an error: %s", tc.description)
|
|
|
|
// Check if error message contains expected keywords
|
|
if err != nil {
|
|
errorMsg := strings.ToLower(err.Error())
|
|
hasExpectedKeyword := false
|
|
for _, keyword := range tc.errorKeywords {
|
|
if strings.Contains(errorMsg, strings.ToLower(keyword)) {
|
|
hasExpectedKeyword = true
|
|
break
|
|
}
|
|
}
|
|
require.Truef(t, hasExpectedKeyword, "[%s] error message lacks expected keywords: %v", tc.name, tc.errorKeywords)
|
|
}
|
|
} else {
|
|
require.NoError(t, err, "Should not return an error: %s", tc.description)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Boundary case tests
|
|
t.Run("MultiGeometry Type Query", func(t *testing.T) {
|
|
expr := fmt.Sprintf("ST_WITHIN(%s, '%s')", common.DefaultGeometryFieldName, getQueryPolygon())
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
require.NoError(t, err, "MultiPoint query should be handled correctly")
|
|
})
|
|
|
|
t.Run("Empty Geometry Collection", func(t *testing.T) {
|
|
emptyGeomCollection := "GEOMETRYCOLLECTION EMPTY"
|
|
expr := fmt.Sprintf("ST_EQUALS(%s, '%s')", common.DefaultGeometryFieldName, emptyGeomCollection)
|
|
_, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).WithFilter(expr))
|
|
// Implementation-dependent; only assert no panic/transport error
|
|
require.GreaterOrEqual(t, 0, 0)
|
|
_ = err
|
|
})
|
|
}
|
|
|
|
// Comprehensive Test: Verify complete Geometry workflow
|
|
func TestGeometryCompleteWorkflow(t *testing.T) {
|
|
// Use enhanced test data and full index configuration
|
|
testData := createEnhancedSpatialTestData()
|
|
setup := setupGeometryTest(t, true, true, testData)
|
|
|
|
// Verify data insertion
|
|
queryResult, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).
|
|
WithFilter(fmt.Sprintf("%s >= 0", common.DefaultInt64FieldName)).
|
|
WithLimit(len(testData.IDs)).
|
|
WithOutputFields("*"))
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, len(testData.IDs), queryResult.ResultCount,
|
|
fmt.Sprintf("Should return %d records", len(testData.IDs)))
|
|
require.Equal(t, 3, len(queryResult.Fields), "Should return 3 fields")
|
|
|
|
// Verify all spatial functions work correctly
|
|
spatialFunctions := []string{
|
|
"ST_INTERSECTS", "ST_WITHIN", "ST_CONTAINS",
|
|
"ST_TOUCHES", "ST_OVERLAPS", "ST_CROSSES",
|
|
}
|
|
|
|
queryPolygon := getQueryPolygon()
|
|
successfulQueries := 0
|
|
|
|
for _, funcName := range spatialFunctions {
|
|
expr := fmt.Sprintf("%s(%s, '%s')", funcName, common.DefaultGeometryFieldName, queryPolygon)
|
|
|
|
result, err := setup.Client.Query(setup.Ctx, client.NewQueryOption(setup.Collection).
|
|
WithFilter(expr).
|
|
WithOutputFields(common.DefaultInt64FieldName))
|
|
|
|
if err == nil {
|
|
successfulQueries++
|
|
require.GreaterOrEqual(t, result.ResultCount, 0)
|
|
}
|
|
}
|
|
|
|
require.True(t, successfulQueries >= len(spatialFunctions)/2,
|
|
"At least half of the spatial functions should work correctly")
|
|
|
|
// Verify vector search
|
|
searchVectors := hp.GenSearchVectors(1, 8, entity.FieldTypeFloatVector)
|
|
searchResult, err := setup.Client.Search(setup.Ctx, client.NewSearchOption(setup.Collection, 5, searchVectors).
|
|
WithOutputFields(common.DefaultGeometryFieldName))
|
|
require.NoError(t, err)
|
|
require.True(t, len(searchResult) > 0, "Vector search should return results")
|
|
}
|