From ff0efac45e66d5003753bbe8a23574cc4c286524 Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Thu, 21 May 2026 00:57:33 +0000 Subject: [PATCH 1/3] Added geospatial tests for $near Signed-off-by: Victor [C] Tsang --- .../operator/query/geospatial/conftest.py | 13 + .../query/geospatial/near/__init__.py | 0 .../near/test_near_argument_handling.py | 106 ++++ .../near/test_near_bson_validator.py | 119 +++++ .../near/test_near_core_functionality.py | 390 ++++++++++++++ .../geospatial/near/test_near_edge_cases.py | 159 ++++++ .../query/geospatial/near/test_near_errors.py | 487 ++++++++++++++++++ .../near/test_near_index_behavior.py | 214 ++++++++ .../query/geospatial/near/test_near_legacy.py | 152 ++++++ documentdb_tests/framework/error_codes.py | 1 + .../framework/test_structure_validator.py | 2 + 11 files changed, 1643 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/conftest.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_edge_cases.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_index_behavior.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/conftest.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/conftest.py new file mode 100644 index 00000000..b61c80d9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/conftest.py @@ -0,0 +1,13 @@ +import pytest + + +@pytest.fixture +def geo_2dsphere(collection): + """Create a 2dsphere index on loc.""" + collection.create_index([("loc", "2dsphere")]) + + +@pytest.fixture +def geo_2d(collection): + """Create a 2d index on loc.""" + collection.create_index([("loc", "2d")]) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py new file mode 100644 index 00000000..93578176 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py @@ -0,0 +1,106 @@ +"""Tests for $near argument handling — valid GeoJSON structures, distance combinations.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.usefixtures("geo_2dsphere") + +NYC_POINT = [-73.9667, 40.78] + + +GEOJSON_STRUCTURE_SUCCESS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="missing_type_defaults_to_point", + filter={"loc": {"$near": {"$geometry": {"coordinates": NYC_POINT}}}}, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": NYC_POINT}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": NYC_POINT}}], + msg="Should accept $geometry without type field (defaults to Point)", + ), + QueryTestCase( + id="valid_geojson_point", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": NYC_POINT}}}}, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": NYC_POINT}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": NYC_POINT}}], + msg="Should accept valid GeoJSON Point", + ), + QueryTestCase( + id="three_coordinates_altitude", + filter={ + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [-73.9667, 40.78, 0]}}} + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": NYC_POINT}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": NYC_POINT}}], + msg="Should accept three coordinates (with altitude)", + ), + QueryTestCase( + id="extra_field_in_geometry", + filter={ + "loc": { + "$near": { + "$geometry": { + "type": "Point", + "coordinates": [0, 0], + "extra": "field", + } + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Should ignore extra fields inside $geometry", + ), +] + +DISTANCE_COMBINATION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="both_min_and_max", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": 0, + "$maxDistance": 1000000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="Should accept both $minDistance and $maxDistance", + ), + QueryTestCase( + id="only_minDistance", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": 0, + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Should accept only $minDistance", + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(GEOJSON_STRUCTURE_SUCCESS_TESTS + DISTANCE_COMBINATION_TESTS) +) +def test_near_valid_argument_handling(collection, test): + """Verifies $near accepts valid GeoJSON structures and distance combinations.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py new file mode 100644 index 00000000..8ac4a32a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py @@ -0,0 +1,119 @@ +""" +Tests for $near BSON type validation. + +Verifies that $near parameters reject invalid BSON types and accept valid ones. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.usefixtures("geo_2dsphere") + +GEOJSON_BSON_PARAMS = [ + BsonTypeTestCase( + id="geometry", + msg="$geometry should reject non-object types", + keyword="$geometry", + valid_types=[BsonType.OBJECT], + valid_inputs={ + BsonType.OBJECT: {"type": "Point", "coordinates": [0, 0]}, + }, + default_error_code=BAD_VALUE_ERROR, + ), + BsonTypeTestCase( + id="maxDistance", + msg="$maxDistance should reject non-numeric types", + keyword="$maxDistance", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + default_error_code=BAD_VALUE_ERROR, + ), + BsonTypeTestCase( + id="minDistance", + msg="$minDistance should reject non-numeric types", + keyword="$minDistance", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + valid_inputs={ + BsonType.DOUBLE: 0.0, + BsonType.INT: 0, + BsonType.LONG: Int64(0), + BsonType.DECIMAL: Decimal128("0"), + }, + default_error_code=BAD_VALUE_ERROR, + ), + BsonTypeTestCase( + id="coordinates", + msg="coordinates should reject non-numeric element types", + keyword="coordinates", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + valid_inputs={ + BsonType.DOUBLE: 0.0, + BsonType.INT: 0, + BsonType.LONG: Int64(0), + BsonType.DECIMAL: Decimal128("0"), + }, + default_error_code=BAD_VALUE_ERROR, + ), +] + +GEOJSON_REJECTION = generate_bson_rejection_test_cases(GEOJSON_BSON_PARAMS) +GEOJSON_ACCEPTANCE = generate_bson_acceptance_test_cases(GEOJSON_BSON_PARAMS) + + +def _build_geojson_filter(spec, sample_value): + """Build GeoJSON $near filter.""" + if spec.keyword == "coordinates": + return { + "loc": { + "$near": { + "$geometry": { + "type": "Point", + "coordinates": [sample_value, sample_value], + } + } + } + } + if spec.keyword == "$geometry": + return {"loc": {"$near": {"$geometry": sample_value}}} + return { + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + spec.keyword: sample_value, + } + } + } + + +@pytest.mark.parametrize("bson_type,sample_value,spec", GEOJSON_REJECTION) +def test_near_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies $near rejects invalid BSON types.""" + result = execute_command( + collection, + {"find": collection.name, "filter": _build_geojson_filter(spec, sample_value)}, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", GEOJSON_ACCEPTANCE) +def test_near_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies $near accepts valid BSON types.""" + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + result = execute_command( + collection, + {"find": collection.name, "filter": _build_geojson_filter(spec, sample_value)}, + ) + assertSuccess( + result, + [{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg=f"{spec.keyword} should accept {bson_type.value}", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py new file mode 100644 index 00000000..ab135376 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py @@ -0,0 +1,390 @@ +"""Tests for $near core functionality — GeoJSON, distance, field paths, interactions.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT64_MAX + +GEOJSON_CORE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="basic_nearest_first", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [1, 1]}}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [1, 1]}}, + {"_id": 1, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="Should return documents sorted nearest to farthest", + ), + QueryTestCase( + id="maxDistance_filters", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 200000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}}, + ], + msg="Should filter documents beyond $maxDistance", + ), + QueryTestCase( + id="minDistance_excludes_close", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": 100000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="Should exclude documents closer than $minDistance", + ), + QueryTestCase( + id="ring_query", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": 100000, + "$maxDistance": 1000000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [3, 3]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [3, 3]}}, + ], + msg="Should return only documents in ring between $minDistance and $maxDistance", + ), + QueryTestCase( + id="empty_result", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 1, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[], + msg="Should return empty when no documents within distance", + ), + QueryTestCase( + id="cross_antimeridian", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [179, 0]}, + "$maxDistance": 300000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [179, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [-179, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [179, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [-179, 0]}}, + ], + msg="Should find nearby point across antimeridian (2 degrees apart, not 358)", + ), +] + +NULL_MISSING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="null_location_not_matched", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 1000000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": None}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + msg="Should not match documents with null location", + ), + QueryTestCase( + id="missing_location_not_matched", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 1000000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "other": "value"}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + msg="Should not match documents with missing location field", + ), +] + +VALID_INTERACTION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="near_inside_and", + filter={ + "$and": [ + {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}, + {"category": "A"}, + ] + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "category": "A"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}, "category": "B"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [2, 2]}, "category": "A"}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "category": "A"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [2, 2]}, "category": "A"}, + ], + msg="Should work inside $and with additional filter", + ), + QueryTestCase( + id="near_with_equality_other_field", + filter={ + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}, + "category": "A", + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "category": "A"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}, "category": "B"}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "category": "A"}, + ], + msg="Should combine with equality on another field", + ), +] + + +DISTANCE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="spherical_distance_excludes", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 50000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Should use spherical distance (meters) — 50km excludes point at 1 degree", + ), + QueryTestCase( + id="spherical_distance_includes", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 120000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + msg="Should include point within 120km", + ), + QueryTestCase( + id="minDistance_greater_than_maxDistance_empty", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": 500000, + "$maxDistance": 100000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + expected=[], + msg="Should return empty when $minDistance > $maxDistance", + ), + QueryTestCase( + id="very_large_maxDistance", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 50000000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [90, 45]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [-120, -30]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [90, 45]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [-120, -30]}}, + ], + msg="Should return all documents with very large $maxDistance", + ), + QueryTestCase( + id="maxDistance_int64_max", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": INT64_MAX, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [180, 0]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [180, 0]}}, + ], + msg="Should return all documents with $maxDistance=INT64_MAX", + ), + QueryTestCase( + id="very_small_maxDistance", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 0.001, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0.001, 0]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Should return only exact point with very small $maxDistance", + ), + QueryTestCase( + id="zero_maxDistance", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 0, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0.001, 0]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Should return only exact-match point with $maxDistance=0", + ), +] + + +@pytest.mark.usefixtures("geo_2dsphere") +@pytest.mark.parametrize( + "test", + pytest_params( + GEOJSON_CORE_TESTS + NULL_MISSING_TESTS + VALID_INTERACTION_TESTS + DISTANCE_TESTS + ), +) +def test_near_core(collection, test): + """Verifies $near core functionality.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, msg=test.msg) + + +def test_near_nested_field(collection): + """Verifies $near works on nested field path.""" + collection.create_index([("address.location", "2dsphere")]) + collection.insert_many( + [ + {"_id": 1, "address": {"location": {"type": "Point", "coordinates": [0, 0]}}}, + {"_id": 2, "address": {"location": {"type": "Point", "coordinates": [5, 5]}}}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "address.location": { + "$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}} + } + }, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "address": {"location": {"type": "Point", "coordinates": [0, 0]}}}, + {"_id": 2, "address": {"location": {"type": "Point", "coordinates": [5, 5]}}}, + ], + msg="Should work on nested field path", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_edge_cases.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_edge_cases.py new file mode 100644 index 00000000..db8cd723 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_edge_cases.py @@ -0,0 +1,159 @@ +"""Tests for $near edge cases — coordinate boundaries, extreme coordinates.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.usefixtures("geo_2dsphere") + + +COORDINATE_BOUNDARY_SUCCESS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="longitude_neg180", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [-180, 0]}, + "$maxDistance": 100000, + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [-180, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [-180, 0]}}], + msg="Should accept longitude = -180", + ), + QueryTestCase( + id="longitude_180", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [180, 0]}, + "$maxDistance": 100000, + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [180, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [180, 0]}}], + msg="Should accept longitude = 180", + ), + QueryTestCase( + id="latitude_neg90", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, -90]}, + "$maxDistance": 100000, + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, -90]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, -90]}}], + msg="Should accept latitude = -90", + ), + QueryTestCase( + id="latitude_90", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 90]}, + "$maxDistance": 100000, + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 90]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 90]}}], + msg="Should accept latitude = 90", + ), + QueryTestCase( + id="negative_zero_coordinate", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [-0.0, 0]}, + "$maxDistance": 100000, + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Should accept negative zero coordinate", + ), +] + +EXTREME_COORDINATE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="north_pole", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 90]}, + "$maxDistance": 20100000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 90]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 90]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + msg="Should query from North Pole", + ), + QueryTestCase( + id="south_pole", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, -90]}, + "$maxDistance": 20100000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, -90]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, -90]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + msg="Should query from South Pole", + ), + QueryTestCase( + id="antimeridian_180", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [180, 0]}, + "$maxDistance": 100000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [180, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [-180, 0]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [180, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [-180, 0]}}, + ], + msg="Should find points at antimeridian (180 and -180 are same location)", + ), +] + + +@pytest.mark.parametrize( + "test", pytest_params(COORDINATE_BOUNDARY_SUCCESS_TESTS + EXTREME_COORDINATE_TESTS) +) +def test_near_edge_cases(collection, test): + """Verifies $near handles boundary and extreme coordinate cases.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py new file mode 100644 index 00000000..cc0560b5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py @@ -0,0 +1,487 @@ +"""Tests for $near error handling — all error/rejection cases consolidated.""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + CANNOT_MIX_GEO_WITH_OTHER_OP_ERROR, + NEAR_NOT_ALLOWED_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_MAX, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +pytestmark = pytest.mark.usefixtures("geo_2dsphere") + + +COORDINATE_BOUNDARY_ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="longitude_below_neg180", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [-180.1, 0]}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject longitude < -180", + ), + QueryTestCase( + id="longitude_above_180", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [180.1, 0]}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject longitude > 180", + ), + QueryTestCase( + id="latitude_below_neg90", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, -90.1]}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject latitude < -90", + ), + QueryTestCase( + id="latitude_above_90", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 90.1]}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject latitude > 90", + ), + QueryTestCase( + id="coordinates_nan", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [FLOAT_NAN, 0]}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject NaN coordinate", + ), + QueryTestCase( + id="coordinates_infinity", + filter={ + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [FLOAT_INFINITY, 0]}}} + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject Infinity coordinate", + ), + QueryTestCase( + id="coordinates_neg_infinity", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [FLOAT_NEGATIVE_INFINITY, 0]} + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject -Infinity coordinate", + ), +] + + +GEOJSON_STRUCTURE_ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="invalid_type_linestring", + filter={ + "loc": { + "$near": { + "$geometry": { + "type": "LineString", + "coordinates": [[-73.9667, 40.78], [-73.9, 40.7]], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject LineString geometry type", + ), + QueryTestCase( + id="invalid_type_polygon", + filter={ + "loc": { + "$near": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [[-73.9, 40.7], [-73.9, 40.8], [-74.0, 40.8], [-73.9, 40.7]] + ], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject Polygon geometry type", + ), + QueryTestCase( + id="invalid_type_multipoint", + filter={ + "loc": { + "$near": { + "$geometry": { + "type": "MultiPoint", + "coordinates": [[-73.9667, 40.78], [-73.9, 40.7]], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject MultiPoint geometry type", + ), + QueryTestCase( + id="missing_coordinates", + filter={"loc": {"$near": {"$geometry": {"type": "Point"}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject $geometry missing coordinates", + ), + QueryTestCase( + id="empty_coordinates", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": []}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject empty coordinates array", + ), + QueryTestCase( + id="single_coordinate", + filter={"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [-73.9667]}}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject single coordinate", + ), + QueryTestCase( + id="null_geometry", + filter={"loc": {"$near": {"$geometry": None}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject null $geometry", + ), + QueryTestCase( + id="string_geometry", + filter={"loc": {"$near": {"$geometry": "invalid"}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject string $geometry", + ), + QueryTestCase( + id="unknown_key_in_near", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$unknownKey": 5, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject unknown keys inside $near", + ), +] + + +INVALID_DISTANCE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="maxDistance_negative", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": -1, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject negative $maxDistance", + ), + QueryTestCase( + id="minDistance_negative", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": -1, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject negative $minDistance", + ), + QueryTestCase( + id="maxDistance_decimal128_max", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": DECIMAL128_MAX, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $maxDistance=DECIMAL128_MAX as non-negative overflow", + ), +] + + +DISTANCE_VALIDATION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="maxDistance_nan", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": FLOAT_NAN, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject NaN $maxDistance", + ), + QueryTestCase( + id="maxDistance_neg_infinity", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": FLOAT_NEGATIVE_INFINITY, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject -Infinity $maxDistance", + ), + QueryTestCase( + id="minDistance_nan", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": FLOAT_NAN, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject NaN $minDistance", + ), + QueryTestCase( + id="minDistance_neg_infinity", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": FLOAT_NEGATIVE_INFINITY, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject -Infinity $minDistance", + ), + QueryTestCase( + id="maxDistance_infinity", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": FLOAT_INFINITY, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject Infinity $maxDistance", + ), + QueryTestCase( + id="minDistance_infinity", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$minDistance": FLOAT_INFINITY, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject Infinity $minDistance", + ), +] + + +ALL_ERROR_TESTS = ( + COORDINATE_BOUNDARY_ERROR_TESTS + + GEOJSON_STRUCTURE_ERROR_TESTS + + INVALID_DISTANCE_TESTS + + DISTANCE_VALIDATION_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) +def test_near_errors(collection, test): + """Verifies $near rejects invalid inputs.""" + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +RESTRICTION_ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="near_without_geometry", + filter={"loc": {"$near": {}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near with empty object (no $geometry)", + ), + QueryTestCase( + id="near_with_only_maxDistance", + filter={"loc": {"$near": {"$maxDistance": 5000}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near with $maxDistance but no $geometry", + ), + QueryTestCase( + id="near_and_nearSphere_same_field", + filter={ + "loc": { + "$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}, + "$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}, + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near and $nearSphere on same field", + ), + QueryTestCase( + id="near_and_nearSphere_different_fields", + filter={ + "loc1": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}, + "loc2": {"$nearSphere": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}, + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near and $nearSphere on different fields", + ), + QueryTestCase( + id="near_inside_or", + filter={ + "$or": [ + {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}, + {"name": "test"}, + ] + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near inside $or", + ), + QueryTestCase( + id="near_inside_nor", + filter={ + "$nor": [{"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}] + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near inside $nor", + ), + QueryTestCase( + id="near_inside_not", + filter={ + "$and": [ + { + "loc": { + "$not": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + } + } + ] + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near inside $not", + ), + QueryTestCase( + id="near_inside_elemMatch", + filter={ + "arr": { + "$elemMatch": { + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $near inside $elemMatch", + ), + QueryTestCase( + id="two_near_different_fields", + filter={ + "loc1": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}, + "loc2": {"$near": {"$geometry": {"type": "Point", "coordinates": [1, 1]}}}, + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject two $near on different fields", + ), + QueryTestCase( + id="two_near_inside_and", + filter={ + "$and": [ + {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}, + {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [1, 1]}}}}, + ] + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject two $near inside $and", + ), + QueryTestCase( + id="near_combined_with_other_op_same_field", + filter={ + "loc": { + "$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}, + "$eq": {"type": "Point", "coordinates": [0, 0]}, + } + }, + error_code=CANNOT_MIX_GEO_WITH_OTHER_OP_ERROR, + msg="Should reject $near combined with $eq on same field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RESTRICTION_ERROR_TESTS)) +def test_near_restriction_errors(collection, test): + """Verifies $near rejects invalid operator combinations.""" + collection.create_index([("loc1", "2dsphere")]) + collection.create_index([("loc2", "2dsphere")]) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_near_in_aggregate_match_errors(collection): + """Verifies $near is not permitted in aggregation pipeline $match (parse-time rejection).""" + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$match": { + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, NEAR_NOT_ALLOWED_ERROR, msg="Should reject $near in aggregate $match") + + +def test_near_combined_with_text_errors(collection): + """Verifies $near cannot be combined with $text in the same query.""" + collection.create_index([("loc", "2dsphere")]) + collection.create_index([("name", "text")]) + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}, + "$text": {"$search": "test"}, + }, + }, + ) + assertFailureCode(result, BAD_VALUE_ERROR, msg="Should reject $near combined with $text") + + +def test_near_on_timeseries_errors(database_client): + """Verifies $near is not allowed on timeseries collections.""" + db = database_client + db.create_collection("ts_coll", timeseries={"timeField": "ts", "metaField": "meta"}) + coll = db["ts_coll"] + result = execute_command( + coll, + { + "find": "ts_coll", + "filter": { + "meta.loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}} + }, + }, + ) + assertFailureCode( + result, NEAR_NOT_ALLOWED_ERROR, msg="Should reject $near on timeseries collection" + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_index_behavior.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_index_behavior.py new file mode 100644 index 00000000..377b5720 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_index_behavior.py @@ -0,0 +1,214 @@ +"""Tests for $near index behavior — index requirements, compound indexes, trailing fields.""" + +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.error_codes import NO_QUERY_EXECUTION_PLANS_ERROR +from documentdb_tests.framework.executor import execute_command + + +def test_near_geojson_without_2dsphere_index_errors(collection): + """Verifies $near GeoJSON fails without 2dsphere index when documents exist.""" + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}, + }, + ) + assertFailureCode( + result, NO_QUERY_EXECUTION_PLANS_ERROR, msg="Should error without 2dsphere index" + ) + + +def test_near_legacy_without_2d_index_errors(collection): + """Verifies $near legacy fails without 2d index when documents exist.""" + collection.insert_one({"_id": 1, "loc": [0, 0]}) + result = execute_command( + collection, + {"find": collection.name, "filter": {"loc": {"$near": [0, 0]}}}, + ) + assertFailureCode(result, NO_QUERY_EXECUTION_PLANS_ERROR, msg="Should error without 2d index") + + +def test_near_geojson_with_only_2d_index_errors(collection): + """Verifies $near GeoJSON fails with only 2d index (needs 2dsphere).""" + collection.create_index([("loc", "2d")]) + collection.insert_one({"_id": 1, "loc": [0, 0]}) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}, + }, + ) + assertFailureCode( + result, NO_QUERY_EXECUTION_PLANS_ERROR, msg="Should error with only 2d index for GeoJSON" + ) + + +def test_near_with_compound_index(collection): + """Verifies $near works with compound index including geo field.""" + collection.create_index([("loc", "2dsphere"), ("category", 1)]) + collection.insert_many( + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "category": "A"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}, "category": "B"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [2, 2]}, "category": "A"}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}, + "category": "A", + }, + }, + ) + assertSuccess( + result, + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "category": "A"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [2, 2]}, "category": "A"}, + ], + msg="Should filter by additional field with compound index", + ) + + +def test_near_2d_with_trailing_field_exists(collection): + """Verifies $near with 2d index and $exists on trailing field.""" + collection.create_index([("loc", "2d")]) + collection.insert_many( + [ + {"_id": 1, "loc": [0, 0], "extra": "value"}, + {"_id": 2, "loc": [1, 1]}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"loc": {"$near": [0, 0]}, "extra": {"$exists": True}}, + }, + ) + assertSuccess( + result, + [{"_id": 1, "loc": [0, 0], "extra": "value"}], + msg="Should filter by $exists on trailing field", + ) + + +def test_near_2d_with_trailing_field_null(collection): + """Verifies $near with 2d index and null check on trailing field.""" + collection.create_index([("loc", "2d")]) + collection.insert_many( + [ + {"_id": 1, "loc": [0, 0], "extra": "value"}, + {"_id": 2, "loc": [1, 1]}, + {"_id": 3, "loc": [0.5, 0.5], "extra": None}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"loc": {"$near": [0, 0]}, "extra": None}, + }, + ) + assertSuccess( + result, + [ + {"_id": 3, "loc": [0.5, 0.5], "extra": None}, + {"_id": 2, "loc": [1, 1]}, + ], + msg="Should match documents where trailing field is null or missing", + ) + + +def test_near_2d_with_trailing_field_not_exists(collection): + """Verifies $near with 2d index and $exists:false on trailing field.""" + collection.create_index([("loc", "2d")]) + collection.insert_many( + [ + {"_id": 1, "loc": [0, 0], "extra": "value"}, + {"_id": 2, "loc": [1, 1]}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"loc": {"$near": [0, 0]}, "extra": {"$exists": False}}, + }, + ) + assertSuccess( + result, + [{"_id": 2, "loc": [1, 1]}], + msg="Should match documents where trailing field does not exist", + ) + + +def test_near_legacy_with_2dsphere_index(collection): + """Verifies legacy $near fails when only 2dsphere index exists.""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + result = execute_command( + collection, + {"find": collection.name, "filter": {"loc": {"$near": [0, 0]}}}, + ) + assertFailureCode( + result, + NO_QUERY_EXECUTION_PLANS_ERROR, + msg="Legacy $near should fail with only 2dsphere index", + ) + + +def test_near_multiple_2dsphere_indexes(collection): + """Verifies $near selects correct index with multiple 2dsphere indexes.""" + collection.create_index([("loc1", "2dsphere")]) + collection.create_index([("loc2", "2dsphere")]) + collection.insert_many( + [ + { + "_id": 1, + "loc1": {"type": "Point", "coordinates": [0, 0]}, + "loc2": {"type": "Point", "coordinates": [50, 50]}, + }, + { + "_id": 2, + "loc1": {"type": "Point", "coordinates": [5, 5]}, + "loc2": {"type": "Point", "coordinates": [0, 0]}, + }, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc1": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0]}, + "$maxDistance": 1000000, + } + } + }, + }, + ) + assertSuccess( + result, + [ + { + "_id": 1, + "loc1": {"type": "Point", "coordinates": [0, 0]}, + "loc2": {"type": "Point", "coordinates": [50, 50]}, + }, + { + "_id": 2, + "loc1": {"type": "Point", "coordinates": [5, 5]}, + "loc2": {"type": "Point", "coordinates": [0, 0]}, + }, + ], + msg="Should use loc1 index and order by distance from loc1", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py new file mode 100644 index 00000000..0fb1895c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py @@ -0,0 +1,152 @@ +"""Tests for $near legacy mode — 2d index, planar distance, coordinate pairs. + +All legacy $near tests are consolidated here. Uses geo_2d fixture. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.query.utils.query_test_case import ( + QueryTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccess +from documentdb_tests.framework.bson_type_validator import ( + BsonType, + BsonTypeTestCase, + generate_bson_acceptance_test_cases, + generate_bson_rejection_test_cases, +) +from documentdb_tests.framework.error_codes import BAD_VALUE_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.usefixtures("geo_2d") + + +LEGACY_CORE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="legacy_with_max_distance", + filter={"loc": {"$near": [0, 0], "$maxDistance": 2}}, + doc=[ + {"_id": 1, "loc": [0, 0]}, + {"_id": 2, "loc": [1, 1]}, + {"_id": 3, "loc": [50, 50]}, + ], + expected=[ + {"_id": 1, "loc": [0, 0]}, + {"_id": 2, "loc": [1, 1]}, + ], + msg="Should filter legacy documents beyond $maxDistance in radians", + ), + QueryTestCase( + id="legacy_with_min_distance", + filter={"loc": {"$near": [0, 0], "$minDistance": 1}}, + doc=[ + {"_id": 1, "loc": [0, 0]}, + {"_id": 2, "loc": [1, 1]}, + ], + expected=[ + {"_id": 2, "loc": [1, 1]}, + ], + msg="Should accept $minDistance in legacy mode and exclude close documents", + ), +] + +LEGACY_ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="legacy_single_element", + filter={"loc": {"$near": [-73.9667]}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject single element legacy array", + ), + QueryTestCase( + id="legacy_empty_array", + filter={"loc": {"$near": []}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject empty legacy array", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LEGACY_CORE_TESTS)) +def test_near_legacy_core(collection, test): + """Verifies $near legacy mode core functionality.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, msg=test.msg) + + +@pytest.mark.parametrize("test", pytest_params(LEGACY_ERROR_TESTS)) +def test_near_legacy_errors(collection, test): + """Verifies $near rejects invalid legacy inputs.""" + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +def test_near_legacy_distance_ordering(collection): + """Verifies $near with 2d index returns results in distance order.""" + collection.insert_many( + [ + {"_id": 1, "loc": [5, 5]}, + {"_id": 2, "loc": [0, 0]}, + {"_id": 3, "loc": [1, 1]}, + ] + ) + result = execute_command( + collection, + {"find": collection.name, "filter": {"loc": {"$near": [0, 0]}}}, + ) + assertSuccess( + result, + [ + {"_id": 2, "loc": [0, 0]}, + {"_id": 3, "loc": [1, 1]}, + {"_id": 1, "loc": [5, 5]}, + ], + msg="Should sort by distance with 2d index", + ) + + +LEGACY_BSON_PARAMS = [ + BsonTypeTestCase( + id="legacy_coordinates", + msg="legacy $near coordinates should reject non-numeric element types", + keyword="legacy_coordinates", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + valid_inputs={ + BsonType.DOUBLE: 0.0, + BsonType.INT: 0, + BsonType.LONG: Int64(0), + BsonType.DECIMAL: Decimal128("0"), + }, + default_error_code=BAD_VALUE_ERROR, + ), +] + +LEGACY_BSON_REJECTION = generate_bson_rejection_test_cases(LEGACY_BSON_PARAMS) +LEGACY_BSON_ACCEPTANCE = generate_bson_acceptance_test_cases(LEGACY_BSON_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", LEGACY_BSON_REJECTION) +def test_near_legacy_bson_type_rejected(collection, bson_type, sample_value, spec): + """Verifies legacy $near rejects invalid BSON types.""" + result = execute_command( + collection, + {"find": collection.name, "filter": {"loc": {"$near": [sample_value, sample_value]}}}, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", LEGACY_BSON_ACCEPTANCE) +def test_near_legacy_bson_type_accepted(collection, bson_type, sample_value, spec): + """Verifies legacy $near accepts valid BSON types.""" + collection.insert_one({"_id": 1, "loc": [0, 0]}) + result = execute_command( + collection, + {"find": collection.name, "filter": {"loc": {"$near": [sample_value, sample_value]}}}, + ) + assertSuccess( + result, + [{"_id": 1, "loc": [0, 0]}], + msg=f"legacy $near should accept {bson_type.value}", + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 5fad1cc9..8e5e665d 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -177,6 +177,7 @@ PROJECT_VALUE_IN_EXCLUSION_ERROR = 31310 PROJECT_UNKNOWN_EXPRESSION_ERROR = 31325 UNION_WITH_SUB_PIPELINE_NOT_ALLOWED_ERROR = 31441 +CANNOT_MIX_GEO_WITH_OTHER_OP_ERROR = 34413 REVERSE_ARRAY_NOT_ARRAY_ERROR = 34435 RANGE_START_NOT_INT32_ERROR = 34443 RANGE_START_NOT_INTEGRAL_ERROR = 34444 diff --git a/documentdb_tests/framework/test_structure_validator.py b/documentdb_tests/framework/test_structure_validator.py index 57f811f0..c0b72544 100644 --- a/documentdb_tests/framework/test_structure_validator.py +++ b/documentdb_tests/framework/test_structure_validator.py @@ -17,6 +17,8 @@ def validate_python_files_in_tests(tests_dir: Path) -> list[str]: for py_file in tests_dir.rglob("*.py"): if py_file.name == "__init__.py": continue + if py_file.name == "conftest.py": + continue if any(folder in py_file.parts for folder in allowed_folders): continue From d4cc5fdb729cc6fbe8fc4ce6d1192121329b42b3 Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Thu, 21 May 2026 18:30:40 +0000 Subject: [PATCH 2/3] moving minDistance and maxDistance tests cases to the dedicated folders Signed-off-by: Victor [C] Tsang --- .../near/test_near_argument_handling.py | 44 +-- .../near/test_near_bson_validator.py | 31 +- .../near/test_near_core_functionality.py | 281 +----------------- .../query/geospatial/near/test_near_errors.py | 141 +-------- .../query/geospatial/near/test_near_legacy.py | 37 --- 5 files changed, 7 insertions(+), 527 deletions(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py index 93578176..0186f852 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_argument_handling.py @@ -57,50 +57,10 @@ ), ] -DISTANCE_COMBINATION_TESTS: list[QueryTestCase] = [ - QueryTestCase( - id="both_min_and_max", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": 0, - "$maxDistance": 1000000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, - ], - msg="Should accept both $minDistance and $maxDistance", - ), - QueryTestCase( - id="only_minDistance", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": 0, - } - } - }, - doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], - expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], - msg="Should accept only $minDistance", - ), -] - -@pytest.mark.parametrize( - "test", pytest_params(GEOJSON_STRUCTURE_SUCCESS_TESTS + DISTANCE_COMBINATION_TESTS) -) +@pytest.mark.parametrize("test", pytest_params(GEOJSON_STRUCTURE_SUCCESS_TESTS)) def test_near_valid_argument_handling(collection, test): - """Verifies $near accepts valid GeoJSON structures and distance combinations.""" + """Verifies $near accepts valid GeoJSON structures.""" collection.insert_many(test.doc) result = execute_command(collection, {"find": collection.name, "filter": test.filter}) assertSuccess(result, test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py index 8ac4a32a..fdbf93e5 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_bson_validator.py @@ -30,26 +30,6 @@ }, default_error_code=BAD_VALUE_ERROR, ), - BsonTypeTestCase( - id="maxDistance", - msg="$maxDistance should reject non-numeric types", - keyword="$maxDistance", - valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], - default_error_code=BAD_VALUE_ERROR, - ), - BsonTypeTestCase( - id="minDistance", - msg="$minDistance should reject non-numeric types", - keyword="$minDistance", - valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], - valid_inputs={ - BsonType.DOUBLE: 0.0, - BsonType.INT: 0, - BsonType.LONG: Int64(0), - BsonType.DECIMAL: Decimal128("0"), - }, - default_error_code=BAD_VALUE_ERROR, - ), BsonTypeTestCase( id="coordinates", msg="coordinates should reject non-numeric element types", @@ -82,16 +62,7 @@ def _build_geojson_filter(spec, sample_value): } } } - if spec.keyword == "$geometry": - return {"loc": {"$near": {"$geometry": sample_value}}} - return { - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - spec.keyword: sample_value, - } - } - } + return {"loc": {"$near": {"$geometry": sample_value}}} @pytest.mark.parametrize("bson_type,sample_value,spec", GEOJSON_REJECTION) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py index ab135376..04a79975 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py @@ -1,4 +1,4 @@ -"""Tests for $near core functionality — GeoJSON, distance, field paths, interactions.""" +"""Tests for $near core functionality — nearest-first sorting, query interactions, nested fields.""" import pytest @@ -8,7 +8,6 @@ from documentdb_tests.framework.assertions import assertSuccess from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params -from documentdb_tests.framework.test_constants import INT64_MAX GEOJSON_CORE_TESTS: list[QueryTestCase] = [ QueryTestCase( @@ -32,145 +31,6 @@ ], msg="Should return documents sorted nearest to farthest", ), - QueryTestCase( - id="maxDistance_filters", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 200000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}}, - {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}}, - ], - msg="Should filter documents beyond $maxDistance", - ), - QueryTestCase( - id="minDistance_excludes_close", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": 100000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, - ], - expected=[ - {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}}, - ], - msg="Should exclude documents closer than $minDistance", - ), - QueryTestCase( - id="ring_query", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": 100000, - "$maxDistance": 1000000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [3, 3]}}, - {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, - ], - expected=[ - {"_id": 2, "loc": {"type": "Point", "coordinates": [3, 3]}}, - ], - msg="Should return only documents in ring between $minDistance and $maxDistance", - ), - QueryTestCase( - id="empty_result", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 1, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [50, 50]}}, - ], - expected=[], - msg="Should return empty when no documents within distance", - ), - QueryTestCase( - id="cross_antimeridian", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [179, 0]}, - "$maxDistance": 300000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [179, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [-179, 0]}}, - {"_id": 3, "loc": {"type": "Point", "coordinates": [0, 0]}}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [179, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [-179, 0]}}, - ], - msg="Should find nearby point across antimeridian (2 degrees apart, not 358)", - ), -] - -NULL_MISSING_TESTS: list[QueryTestCase] = [ - QueryTestCase( - id="null_location_not_matched", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 1000000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": None}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - ], - msg="Should not match documents with null location", - ), - QueryTestCase( - id="missing_location_not_matched", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 1000000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "other": "value"}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - ], - msg="Should not match documents with missing location field", - ), ] VALID_INTERACTION_TESTS: list[QueryTestCase] = [ @@ -211,147 +71,10 @@ ] -DISTANCE_TESTS: list[QueryTestCase] = [ - QueryTestCase( - id="spherical_distance_excludes", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 50000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, - ], - expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], - msg="Should use spherical distance (meters) — 50km excludes point at 1 degree", - ), - QueryTestCase( - id="spherical_distance_includes", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 120000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, - ], - msg="Should include point within 120km", - ), - QueryTestCase( - id="minDistance_greater_than_maxDistance_empty", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": 500000, - "$maxDistance": 100000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, - ], - expected=[], - msg="Should return empty when $minDistance > $maxDistance", - ), - QueryTestCase( - id="very_large_maxDistance", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 50000000, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [90, 45]}}, - {"_id": 3, "loc": {"type": "Point", "coordinates": [-120, -30]}}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [90, 45]}}, - {"_id": 3, "loc": {"type": "Point", "coordinates": [-120, -30]}}, - ], - msg="Should return all documents with very large $maxDistance", - ), - QueryTestCase( - id="maxDistance_int64_max", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": INT64_MAX, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [180, 0]}}, - ], - expected=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [180, 0]}}, - ], - msg="Should return all documents with $maxDistance=INT64_MAX", - ), - QueryTestCase( - id="very_small_maxDistance", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 0.001, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [0.001, 0]}}, - ], - expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], - msg="Should return only exact point with very small $maxDistance", - ), - QueryTestCase( - id="zero_maxDistance", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": 0, - } - } - }, - doc=[ - {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, - {"_id": 2, "loc": {"type": "Point", "coordinates": [0.001, 0]}}, - ], - expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], - msg="Should return only exact-match point with $maxDistance=0", - ), -] - - @pytest.mark.usefixtures("geo_2dsphere") @pytest.mark.parametrize( "test", - pytest_params( - GEOJSON_CORE_TESTS + NULL_MISSING_TESTS + VALID_INTERACTION_TESTS + DISTANCE_TESTS - ), + pytest_params(GEOJSON_CORE_TESTS + VALID_INTERACTION_TESTS), ) def test_near_core(collection, test): """Verifies $near core functionality.""" diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py index cc0560b5..ad7988f9 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py @@ -1,4 +1,4 @@ -"""Tests for $near error handling — all error/rejection cases consolidated.""" +"""Tests for $near error handling — invalid coordinates, GeoJSON structure, restrictions.""" import pytest @@ -14,7 +14,6 @@ from documentdb_tests.framework.executor import execute_command from documentdb_tests.framework.parametrize import pytest_params from documentdb_tests.framework.test_constants import ( - DECIMAL128_MAX, FLOAT_INFINITY, FLOAT_NAN, FLOAT_NEGATIVE_INFINITY, @@ -171,137 +170,7 @@ ] -INVALID_DISTANCE_TESTS: list[QueryTestCase] = [ - QueryTestCase( - id="maxDistance_negative", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": -1, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject negative $maxDistance", - ), - QueryTestCase( - id="minDistance_negative", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": -1, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject negative $minDistance", - ), - QueryTestCase( - id="maxDistance_decimal128_max", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": DECIMAL128_MAX, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject $maxDistance=DECIMAL128_MAX as non-negative overflow", - ), -] - - -DISTANCE_VALIDATION_TESTS: list[QueryTestCase] = [ - QueryTestCase( - id="maxDistance_nan", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": FLOAT_NAN, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject NaN $maxDistance", - ), - QueryTestCase( - id="maxDistance_neg_infinity", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": FLOAT_NEGATIVE_INFINITY, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject -Infinity $maxDistance", - ), - QueryTestCase( - id="minDistance_nan", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": FLOAT_NAN, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject NaN $minDistance", - ), - QueryTestCase( - id="minDistance_neg_infinity", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": FLOAT_NEGATIVE_INFINITY, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject -Infinity $minDistance", - ), - QueryTestCase( - id="maxDistance_infinity", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$maxDistance": FLOAT_INFINITY, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject Infinity $maxDistance", - ), - QueryTestCase( - id="minDistance_infinity", - filter={ - "loc": { - "$near": { - "$geometry": {"type": "Point", "coordinates": [0, 0]}, - "$minDistance": FLOAT_INFINITY, - } - } - }, - error_code=BAD_VALUE_ERROR, - msg="Should reject Infinity $minDistance", - ), -] - - -ALL_ERROR_TESTS = ( - COORDINATE_BOUNDARY_ERROR_TESTS - + GEOJSON_STRUCTURE_ERROR_TESTS - + INVALID_DISTANCE_TESTS - + DISTANCE_VALIDATION_TESTS -) +ALL_ERROR_TESTS = COORDINATE_BOUNDARY_ERROR_TESTS + GEOJSON_STRUCTURE_ERROR_TESTS @pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) @@ -318,12 +187,6 @@ def test_near_errors(collection, test): error_code=BAD_VALUE_ERROR, msg="Should reject $near with empty object (no $geometry)", ), - QueryTestCase( - id="near_with_only_maxDistance", - filter={"loc": {"$near": {"$maxDistance": 5000}}}, - error_code=BAD_VALUE_ERROR, - msg="Should reject $near with $maxDistance but no $geometry", - ), QueryTestCase( id="near_and_nearSphere_same_field", filter={ diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py index 0fb1895c..7139b55c 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_legacy.py @@ -23,35 +23,6 @@ pytestmark = pytest.mark.usefixtures("geo_2d") -LEGACY_CORE_TESTS: list[QueryTestCase] = [ - QueryTestCase( - id="legacy_with_max_distance", - filter={"loc": {"$near": [0, 0], "$maxDistance": 2}}, - doc=[ - {"_id": 1, "loc": [0, 0]}, - {"_id": 2, "loc": [1, 1]}, - {"_id": 3, "loc": [50, 50]}, - ], - expected=[ - {"_id": 1, "loc": [0, 0]}, - {"_id": 2, "loc": [1, 1]}, - ], - msg="Should filter legacy documents beyond $maxDistance in radians", - ), - QueryTestCase( - id="legacy_with_min_distance", - filter={"loc": {"$near": [0, 0], "$minDistance": 1}}, - doc=[ - {"_id": 1, "loc": [0, 0]}, - {"_id": 2, "loc": [1, 1]}, - ], - expected=[ - {"_id": 2, "loc": [1, 1]}, - ], - msg="Should accept $minDistance in legacy mode and exclude close documents", - ), -] - LEGACY_ERROR_TESTS: list[QueryTestCase] = [ QueryTestCase( id="legacy_single_element", @@ -68,14 +39,6 @@ ] -@pytest.mark.parametrize("test", pytest_params(LEGACY_CORE_TESTS)) -def test_near_legacy_core(collection, test): - """Verifies $near legacy mode core functionality.""" - collection.insert_many(test.doc) - result = execute_command(collection, {"find": collection.name, "filter": test.filter}) - assertSuccess(result, test.expected, msg=test.msg) - - @pytest.mark.parametrize("test", pytest_params(LEGACY_ERROR_TESTS)) def test_near_legacy_errors(collection, test): """Verifies $near rejects invalid legacy inputs.""" From 8d881cebad7c51279c06fc5a43c35f1b641b8c7a Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Tue, 26 May 2026 20:42:58 +0000 Subject: [PATCH 3/3] revised based on reviwer comments Signed-off-by: Victor [C] Tsang --- .../near/test_near_core_functionality.py | 29 +++++++++++++++++++ .../query/geospatial/near/test_near_errors.py | 1 - 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py index 04a79975..13c33fb1 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_core_functionality.py @@ -83,6 +83,35 @@ def test_near_core(collection, test): assertSuccess(result, test.expected, msg=test.msg) +@pytest.mark.usefixtures("geo_2dsphere") +def test_near_explicit_sort_overrides_distance(collection): + """Verifies explicit sort overrides $near distance ordering.""" + collection.insert_many( + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "rank": 3}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}, "rank": 1}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [2, 0]}, "rank": 2}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}, + "sort": {"rank": 1}, + }, + ) + assertSuccess( + result, + [ + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}, "rank": 1}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [2, 0]}, "rank": 2}, + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "rank": 3}, + ], + msg="Should sort by explicit field, overriding distance", + ) + + def test_near_nested_field(collection): """Verifies $near works on nested field path.""" collection.create_index([("address.location", "2dsphere")]) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py index ad7988f9..72fe3092 100644 --- a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/near/test_near_errors.py @@ -316,7 +316,6 @@ def test_near_in_aggregate_match_errors(collection): def test_near_combined_with_text_errors(collection): """Verifies $near cannot be combined with $text in the same query.""" - collection.create_index([("loc", "2dsphere")]) collection.create_index([("name", "text")]) result = execute_command( collection,