From 86be18fae5b313c4043404e260b8addc2690e4cb Mon Sep 17 00:00:00 2001 From: Victor Tsang Date: Thu, 28 May 2026 13:59:08 -0700 Subject: [PATCH] Added geospatial tests for $minDistance Signed-off-by: Victor Tsang --- .../specifiers/minDistance/__init__.py | 0 .../test_minDistance_bson_validator.py | 129 +++++ .../minDistance/test_minDistance_errors.py | 236 ++++++++ .../minDistance/test_minDistance_geojson.py | 530 ++++++++++++++++++ .../minDistance/test_minDistance_legacy.py | 189 +++++++ documentdb_tests/framework/error_codes.py | 3 + 6 files changed, 1087 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_bson_validator.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_errors.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_geojson.py create mode 100644 documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_legacy.py diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/__init__.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_bson_validator.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_bson_validator.py new file mode 100644 index 00000000..566c576a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_bson_validator.py @@ -0,0 +1,129 @@ +"""Tests for $minDistance BSON type validation — GeoJSON and legacy syntax. + +The legacy code path returns a different error code (16893) than GeoJSON (BAD_VALUE_ERROR = 2). +""" + +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, + LEGACY_MIN_DISTANCE_NOT_NUMBER_ERROR, +) +from documentdb_tests.framework.executor import execute_command + +ORIGIN = {"type": "Point", "coordinates": [0, 0]} + +# --- GeoJSON (2dsphere index) --- + +GEOJSON_BSON_PARAMS = [ + 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, + ), +] + +GEOJSON_REJECTION = generate_bson_rejection_test_cases(GEOJSON_BSON_PARAMS) +GEOJSON_ACCEPTANCE = generate_bson_acceptance_test_cases(GEOJSON_BSON_PARAMS) + + +@pytest.mark.parametrize("operator", ["$near", "$nearSphere"]) +@pytest.mark.parametrize("bson_type,sample_value,spec", GEOJSON_REJECTION) +def test_minDistance_geojson_bson_rejected(collection, operator, bson_type, sample_value, spec): + """Verifies $minDistance rejects invalid BSON types (GeoJSON).""" + collection.create_index([("loc", "2dsphere")]) + filt = { + "loc": { + operator: { + "$geometry": ORIGIN, + spec.keyword: sample_value, + } + } + } + result = execute_command(collection, {"find": collection.name, "filter": filt}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("operator", ["$near", "$nearSphere"]) +@pytest.mark.parametrize("bson_type,sample_value,spec", GEOJSON_ACCEPTANCE) +def test_minDistance_geojson_bson_accepted(collection, operator, bson_type, sample_value, spec): + """Verifies $minDistance accepts valid BSON types (GeoJSON).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_one({"_id": 1, "loc": ORIGIN}) + filt = { + "loc": { + operator: { + "$geometry": ORIGIN, + spec.keyword: sample_value, + } + } + } + result = execute_command(collection, {"find": collection.name, "filter": filt}) + assertSuccess( + result, + [{"_id": 1, "loc": ORIGIN}], + msg=f"{spec.keyword} should accept {bson_type.value}", + ) + + +# --- Legacy (2d index) --- + +LEGACY_BSON_PARAMS = [ + BsonTypeTestCase( + id="minDistance_legacy", + msg="$minDistance should reject non-numeric types (legacy)", + 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=LEGACY_MIN_DISTANCE_NOT_NUMBER_ERROR, + ), +] + +LEGACY_REJECTION = generate_bson_rejection_test_cases(LEGACY_BSON_PARAMS) +LEGACY_ACCEPTANCE = generate_bson_acceptance_test_cases(LEGACY_BSON_PARAMS) + + +@pytest.mark.parametrize("operator", ["$near", "$nearSphere"]) +@pytest.mark.parametrize("bson_type,sample_value,spec", LEGACY_REJECTION) +def test_minDistance_legacy_bson_rejected(collection, operator, bson_type, sample_value, spec): + """Verifies $minDistance rejects invalid BSON types (legacy).""" + collection.create_index([("loc", "2d")]) + filt = {"loc": {operator: [0, 0], spec.keyword: sample_value}} + result = execute_command(collection, {"find": collection.name, "filter": filt}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +@pytest.mark.parametrize("operator", ["$near", "$nearSphere"]) +@pytest.mark.parametrize("bson_type,sample_value,spec", LEGACY_ACCEPTANCE) +def test_minDistance_legacy_bson_accepted(collection, operator, bson_type, sample_value, spec): + """Verifies $minDistance accepts valid BSON types (legacy).""" + collection.create_index([("loc", "2d")]) + collection.insert_one({"_id": 1, "loc": [0, 0]}) + filt = {"loc": {operator: [0, 0], spec.keyword: sample_value}} + result = execute_command(collection, {"find": collection.name, "filter": filt}) + assertSuccess( + result, + [{"_id": 1, "loc": [0, 0]}], + msg=f"{spec.keyword} should accept {bson_type.value} (legacy)", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_errors.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_errors.py new file mode 100644 index 00000000..525c9adc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_errors.py @@ -0,0 +1,236 @@ +"""Tests for $minDistance error cases — invalid context, values, types, and missing index.""" + +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 +from documentdb_tests.framework.error_codes import ( + BAD_VALUE_ERROR, + LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + NO_QUERY_EXECUTION_PLANS_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_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + FLOAT_NEGATIVE_NAN, +) + +ORIGIN = {"type": "Point", "coordinates": [0, 0]} + + +ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="without_near_context", + filter={"loc": {"$minDistance": 1000}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject $minDistance without $near or $nearSphere context", + ), + QueryTestCase( + id="minDistance_with_geoWithin", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], + }, + "$minDistance": 1000, + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $minDistance with $geoWithin (invalid context)", + ), + QueryTestCase( + id="nearSphere_minDistance_without_geometry", + filter={ + "loc": { + "$nearSphere": {"$minDistance": 1000, "$maxDistance": 5000}, + } + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject $nearSphere with $minDistance but no $geometry or coordinates", + ), +] + + +INVALID_VALUE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="negative_int", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": -1}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject negative int $minDistance", + ), + QueryTestCase( + id="negative_double", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": -1.5}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject negative double $minDistance", + ), + QueryTestCase( + id="negative_int64", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": Int64(-1)}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject negative Int64 $minDistance", + ), + QueryTestCase( + id="negative_decimal128", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": Decimal128("-1")}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject negative Decimal128 $minDistance", + ), + QueryTestCase( + id="nan_double", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": FLOAT_NAN}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject NaN $minDistance", + ), + QueryTestCase( + id="negative_nan_double", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": FLOAT_NEGATIVE_NAN}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject -NaN $minDistance", + ), + QueryTestCase( + id="infinity", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": FLOAT_INFINITY}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject Infinity $minDistance", + ), + QueryTestCase( + id="negative_infinity", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": FLOAT_NEGATIVE_INFINITY}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject -Infinity $minDistance", + ), + QueryTestCase( + id="nan_decimal128", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": DECIMAL128_NAN}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject Decimal128 NaN $minDistance", + ), + QueryTestCase( + id="negative_nan_decimal128", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": DECIMAL128_NEGATIVE_NAN}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject Decimal128 -NaN $minDistance", + ), + QueryTestCase( + id="infinity_decimal128", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": DECIMAL128_INFINITY}}}, + error_code=BAD_VALUE_ERROR, + msg="Should reject Decimal128 Infinity $minDistance", + ), + QueryTestCase( + id="negative_infinity_decimal128", + filter={ + "loc": {"$near": {"$geometry": ORIGIN, "$minDistance": DECIMAL128_NEGATIVE_INFINITY}} + }, + error_code=BAD_VALUE_ERROR, + msg="Should reject Decimal128 -Infinity $minDistance", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ERROR_TESTS + INVALID_VALUE_TESTS)) +def test_minDistance_errors(collection, test): + """Verifies $minDistance rejects invalid contexts and values.""" + collection.create_index([("loc", "2dsphere")]) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +# --- Legacy (2d) path invalid values --- + +LEGACY_INVALID_VALUE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="legacy_negative_int", + filter={"loc": {"$near": [0, 0], "$minDistance": -1}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject negative int $minDistance with legacy 2d", + ), + QueryTestCase( + id="legacy_negative_double", + filter={"loc": {"$near": [0, 0], "$minDistance": -1.5}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject negative double $minDistance with legacy 2d", + ), + QueryTestCase( + id="legacy_nan_double", + filter={"loc": {"$near": [0, 0], "$minDistance": FLOAT_NAN}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject NaN $minDistance with legacy 2d", + ), + QueryTestCase( + id="legacy_negative_nan_double", + filter={"loc": {"$near": [0, 0], "$minDistance": FLOAT_NEGATIVE_NAN}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject -NaN $minDistance with legacy 2d", + ), + QueryTestCase( + id="legacy_negative_infinity", + filter={"loc": {"$near": [0, 0], "$minDistance": FLOAT_NEGATIVE_INFINITY}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject -Infinity $minDistance with legacy 2d", + ), + QueryTestCase( + id="legacy_nan_decimal128", + filter={"loc": {"$near": [0, 0], "$minDistance": DECIMAL128_NAN}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject Decimal128 NaN $minDistance with legacy 2d", + ), + QueryTestCase( + id="legacy_negative_nan_decimal128", + filter={"loc": {"$near": [0, 0], "$minDistance": DECIMAL128_NEGATIVE_NAN}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject Decimal128 -NaN $minDistance with legacy 2d", + ), + QueryTestCase( + id="legacy_negative_infinity_decimal128", + filter={"loc": {"$near": [0, 0], "$minDistance": DECIMAL128_NEGATIVE_INFINITY}}, + error_code=LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR, + msg="Should reject Decimal128 -Infinity $minDistance with legacy 2d", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LEGACY_INVALID_VALUE_TESTS)) +def test_minDistance_legacy_errors(collection, test): + """Verifies $minDistance rejects invalid values with legacy 2d index.""" + collection.create_index([("loc", "2d")]) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertFailureCode(result, test.error_code, msg=test.msg) + + +NO_INDEX_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="near_without_index", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 1000}}}, + error_code=NO_QUERY_EXECUTION_PLANS_ERROR, + msg="Should error when no geospatial index exists", + ), + QueryTestCase( + id="nearSphere_without_index", + filter={"loc": {"$nearSphere": {"$geometry": ORIGIN, "$minDistance": 1000}}}, + error_code=NO_QUERY_EXECUTION_PLANS_ERROR, + msg="Should error when no geospatial index for $nearSphere", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NO_INDEX_TESTS)) +def test_minDistance_no_index_errors(collection, test): + """Verifies $minDistance fails without geospatial index.""" + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertFailureCode(result, test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_geojson.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_geojson.py new file mode 100644 index 00000000..5ca0fef1 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_geojson.py @@ -0,0 +1,530 @@ +"""Tests for $minDistance with GeoJSON geometry (2dsphere index).""" + +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 assertSuccess +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_MAX, + DOUBLE_NEGATIVE_ZERO, + INT32_MAX, + INT64_MAX, +) + +ORIGIN = {"type": "Point", "coordinates": [0, 0]} +DOC_AT_ORIGIN = [{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}] + +EARTH_CIRCUMFERENCE_METERS = 40075017 +EARTH_HALF_CIRCUMFERENCE_METERS = 20037508 + +# Points at known approximate distances from [0, 0]: +# [1, 0] ≈ 111km, [5, 5] ≈ 786km, [10, 10] ≈ 1568km +DOCS = [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [10, 10]}}, +] + + +NEAR_GEOJSON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="excludes_docs_closer_than_minDistance", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 200000}}}, + doc=DOCS, + expected=[ + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [10, 10]}}, + ], + msg="Should exclude documents closer than 200km from origin", + ), + QueryTestCase( + id="zero_minDistance_includes_all", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 0}}}, + doc=DOCS, + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [10, 10]}}, + ], + msg="Should include all documents when $minDistance is 0", + ), + QueryTestCase( + id="results_sorted_by_distance", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 100000}}}, + doc=DOCS, + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [10, 10]}}, + ], + msg="Should return results sorted nearest first among those beyond minDistance", + ), + QueryTestCase( + id="large_minDistance_excludes_all", + filter={ + "loc": {"$near": {"$geometry": ORIGIN, "$minDistance": EARTH_CIRCUMFERENCE_METERS}} + }, + doc=DOCS, + expected=[], + msg="Should return empty when minDistance exceeds earth circumference", + ), + QueryTestCase( + id="distance_in_meters_precision", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 112000}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + expected=[ + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="Should use meters for GeoJSON — 112km excludes point at ~111km", + ), + QueryTestCase( + id="excludes_exact_center", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 1}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + msg="Should exclude document at exact center when minDistance > 0", + ), +] + + +VALID_VALUE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="positive_int", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 1000}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept positive int $minDistance", + ), + QueryTestCase( + id="positive_double", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 1000.5}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept positive double $minDistance", + ), + QueryTestCase( + id="positive_int64", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": Int64(1000)}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept positive Int64 $minDistance", + ), + QueryTestCase( + id="positive_decimal128", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": Decimal128("1000")}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept positive Decimal128 $minDistance", + ), + QueryTestCase( + id="int32_max", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": INT32_MAX}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept INT32_MAX as $minDistance", + ), + QueryTestCase( + id="int64_max", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": INT64_MAX}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept INT64_MAX as $minDistance", + ), + QueryTestCase( + id="double_max", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": DOUBLE_MAX}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept DOUBLE_MAX as $minDistance", + ), + QueryTestCase( + id="very_small_positive", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 1e-10}}}, + doc=DOC_AT_ORIGIN, + expected=[], + msg="Should accept very small positive double as $minDistance", + ), + QueryTestCase( + id="negative_zero_double", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": DOUBLE_NEGATIVE_ZERO}}}, + doc=DOC_AT_ORIGIN, + expected=DOC_AT_ORIGIN, + msg="Should accept DOUBLE_NEGATIVE_ZERO as $minDistance (treated as 0)", + ), + QueryTestCase( + id="negative_zero_decimal128", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": DECIMAL128_NEGATIVE_ZERO}}}, + doc=DOC_AT_ORIGIN, + expected=DOC_AT_ORIGIN, + msg="Should accept DECIMAL128_NEGATIVE_ZERO as $minDistance (treated as 0)", + ), +] + + +BOUNDARY_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="very_small_minDistance_1mm", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 0.001}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + ], + msg="Should filter with sub-meter precision (1mm minDistance excludes exact center)", + ), + QueryTestCase( + id="half_earth_circumference", + filter={ + "loc": {"$near": {"$geometry": ORIGIN, "$minDistance": EARTH_HALF_CIRCUMFERENCE_METERS}} + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [180, 0]}}, + ], + expected=[], + msg="Should return empty — antipodal point is ~20037km, within 20037.5km minDistance", + ), + QueryTestCase( + id="antimeridian_crossing", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [-179.5, 0]}, + "$minDistance": 200000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [-179.5, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [179.5, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[ + {"_id": 3, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + msg="Should correctly handle antimeridian — [179.5,0] is ~111km from [-179.5,0]", + ), + QueryTestCase( + id="pole_query", + filter={ + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 90]}, + "$minDistance": 1000000, + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 89]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 80]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 80]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + msg="Should correctly calculate distance from North Pole", + ), + QueryTestCase( + id="empty_collection", + filter={"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 1000}}}, + doc=[], + expected=[], + msg="Should return empty array on empty collection without error", + ), +] + + +INTERACTION_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="normal_annular_region", + filter={ + "loc": { + "$near": { + "$geometry": ORIGIN, + "$minDistance": 100000, + "$maxDistance": 1000000, + } + } + }, + doc=DOCS, + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="Should return docs in annular region (100km-1000km)", + ), + QueryTestCase( + id="min_greater_than_max_empty", + filter={ + "loc": { + "$near": { + "$geometry": ORIGIN, + "$minDistance": 5000000, + "$maxDistance": 1000000, + } + } + }, + doc=DOCS, + expected=[], + msg="Should return empty when $minDistance > $maxDistance", + ), + QueryTestCase( + id="min_equals_max", + filter={ + "loc": { + "$near": { + "$geometry": ORIGIN, + "$minDistance": 1000000, + "$maxDistance": 1000000, + } + } + }, + doc=DOCS, + expected=[], + msg="Should return empty when min equals max and no doc is at exactly that distance", + ), + QueryTestCase( + id="min_zero_max_5000km", + filter={ + "loc": { + "$near": { + "$geometry": ORIGIN, + "$minDistance": 0, + "$maxDistance": 5000000, + } + } + }, + doc=DOCS, + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [10, 10]}}, + ], + msg="Should behave as maxDistance alone when minDistance is 0", + ), + QueryTestCase( + id="min_zero_max_zero", + filter={ + "loc": { + "$near": { + "$geometry": ORIGIN, + "$minDistance": 0, + "$maxDistance": 0, + } + } + }, + doc=DOCS, + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + msg="Should return only exact location match when both min and max are 0", + ), + QueryTestCase( + id="tight_annular_includes_one", + filter={ + "loc": { + "$near": { + "$geometry": ORIGIN, + "$minDistance": 700000, + "$maxDistance": 900000, + } + } + }, + doc=DOCS, + expected=[ + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="Should return only docs in tight annular region (700km-900km)", + ), +] + + +NEARSPHERE_GEOJSON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="geojson_minDistance_meters", + filter={ + "loc": { + "$nearSphere": { + "$geometry": ORIGIN, + "$minDistance": 200000, + } + } + }, + doc=DOCS, + expected=[ + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [10, 10]}}, + ], + msg="Should exclude docs closer than 200km with $nearSphere + GeoJSON", + ), + QueryTestCase( + id="geojson_minDistance_zero", + filter={ + "loc": { + "$nearSphere": { + "$geometry": ORIGIN, + "$minDistance": 0, + } + } + }, + doc=DOCS, + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [10, 10]}}, + ], + msg="Should include all docs when $minDistance is 0 with $nearSphere + GeoJSON", + ), + QueryTestCase( + id="geojson_annular_region", + filter={ + "loc": { + "$nearSphere": { + "$geometry": ORIGIN, + "$minDistance": 500000, + "$maxDistance": 1000000, + } + } + }, + doc=DOCS, + expected=[ + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="Should return docs in annular region with $nearSphere + GeoJSON", + ), + QueryTestCase( + id="geojson_large_minDistance_empty", + filter={ + "loc": { + "$nearSphere": { + "$geometry": ORIGIN, + "$minDistance": EARTH_CIRCUMFERENCE_METERS, + } + } + }, + doc=DOCS, + expected=[], + msg="Should return empty when minDistance exceeds earth circumference", + ), +] + + +ALL_TESTS = NEAR_GEOJSON_TESTS + VALID_VALUE_TESTS + BOUNDARY_TESTS + INTERACTION_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_minDistance_near_geojson(collection, test): + """Verifies $minDistance with $near and GeoJSON geometry.""" + collection.create_index([("loc", "2dsphere")]) + if test.doc: + 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(NEARSPHERE_GEOJSON_TESTS)) +def test_minDistance_nearSphere_geojson(collection, test): + """Verifies $minDistance with $nearSphere and GeoJSON Point (meters).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, msg=test.msg) + + +def test_minDistance_sparse_index(collection): + """Verifies $minDistance works with sparse 2dsphere index.""" + collection.create_index([("loc", "2dsphere")], sparse=True) + collection.insert_many( + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "name": "no location field"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 100000}}}, + }, + ) + assertSuccess( + result, + [{"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}], + msg="Should work with sparse index, excluding close docs and skipping missing", + ) + + +def test_minDistance_3d_coordinates_altitude_ignored(collection): + """Verifies $minDistance ignores altitude (3rd coordinate).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_many( + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0, 10000]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5, 0]}}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": { + "$near": { + "$geometry": {"type": "Point", "coordinates": [0, 0, 5000]}, + "$minDistance": 100000, + } + } + }, + }, + ) + assertSuccess( + result, + [{"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5, 0]}}], + msg="Should ignore altitude — distance calculated on 2D surface only", + ) + + +def test_minDistance_nested_field_path(collection): + """Verifies $minDistance works with a nested (dotted) location field.""" + collection.create_index([("address.loc", "2dsphere")]) + collection.insert_many( + [ + {"_id": 1, "address": {"loc": {"type": "Point", "coordinates": [0, 0]}}}, + {"_id": 2, "address": {"loc": {"type": "Point", "coordinates": [5, 5]}}}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": {"address.loc": {"$near": {"$geometry": ORIGIN, "$minDistance": 200000}}}, + }, + ) + assertSuccess( + result, + [{"_id": 2, "address": {"loc": {"type": "Point", "coordinates": [5, 5]}}}], + msg="Should filter by $minDistance on nested field path", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_legacy.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_legacy.py new file mode 100644 index 00000000..02bee175 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/specifiers/minDistance/test_minDistance_legacy.py @@ -0,0 +1,189 @@ +"""Tests for $minDistance with legacy coordinates (2d index).""" + +import math + +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 + +EARTH_RADIUS_KM = 6371 + + +def km_to_radians(km): + return km / EARTH_RADIUS_KM + + +LEGACY_DOCS = [ + {"_id": 1, "loc": [40, 0]}, + {"_id": 2, "loc": [41, 0]}, + {"_id": 3, "loc": [42, 0]}, +] + +NEAR_2D_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="near_2d_no_minDistance", + filter={"loc": {"$near": [0, 0]}}, + doc=LEGACY_DOCS, + expected=[ + {"_id": 1, "loc": [40, 0]}, + {"_id": 2, "loc": [41, 0]}, + {"_id": 3, "loc": [42, 0]}, + ], + msg="Should return all docs sorted by distance without minDistance", + ), + QueryTestCase( + id="near_2d_with_minDistance", + filter={"loc": {"$near": [0, 0], "$minDistance": 41.5}}, + doc=LEGACY_DOCS, + expected=[ + {"_id": 3, "loc": [42, 0]}, + ], + msg="Should exclude docs closer than minDistance=41.5 with 2d index", + ), + QueryTestCase( + id="near_2d_minDistance_zero", + filter={"loc": {"$near": [0, 0], "$minDistance": 0}}, + doc=LEGACY_DOCS, + expected=[ + {"_id": 1, "loc": [40, 0]}, + {"_id": 2, "loc": [41, 0]}, + {"_id": 3, "loc": [42, 0]}, + ], + msg="Should include all docs when minDistance is 0 with 2d index", + ), + QueryTestCase( + id="nearSphere_2d_no_minDistance", + filter={"loc": {"$nearSphere": [0, 0]}}, + doc=LEGACY_DOCS, + expected=[ + {"_id": 1, "loc": [40, 0]}, + {"_id": 2, "loc": [41, 0]}, + {"_id": 3, "loc": [42, 0]}, + ], + msg="Should return all docs sorted by spherical distance without minDistance", + ), + QueryTestCase( + id="nearSphere_2d_with_minDistance_radians", + filter={ + "loc": { + "$nearSphere": [0, 0], + "$minDistance": math.radians(41.5), + } + }, + doc=LEGACY_DOCS, + expected=[ + {"_id": 3, "loc": [42, 0]}, + ], + msg="Should exclude docs closer than minDistance in radians with $nearSphere + 2d", + ), +] + +NEARSPHERE_LEGACY_DOCS = [ + {"_id": 1, "loc": [0, 0]}, + {"_id": 2, "loc": [1, 0]}, + {"_id": 3, "loc": [5, 5]}, + {"_id": 4, "loc": [10, 10]}, +] + +NEARSPHERE_LEGACY_COORDS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="legacy_coords_minDistance_radians", + filter={ + "loc": { + "$nearSphere": [0, 0], + "$minDistance": km_to_radians(200), + } + }, + doc=NEARSPHERE_LEGACY_DOCS, + expected=[ + {"_id": 3, "loc": [5, 5]}, + {"_id": 4, "loc": [10, 10]}, + ], + msg="Should exclude docs closer than 200km (radians) with $nearSphere + legacy", + ), + QueryTestCase( + id="legacy_coords_minDistance_zero", + filter={ + "loc": { + "$nearSphere": [0, 0], + "$minDistance": 0, + } + }, + doc=NEARSPHERE_LEGACY_DOCS, + expected=[ + {"_id": 1, "loc": [0, 0]}, + {"_id": 2, "loc": [1, 0]}, + {"_id": 3, "loc": [5, 5]}, + {"_id": 4, "loc": [10, 10]}, + ], + msg="Should include all docs when $minDistance is 0 with legacy coordinates", + ), + QueryTestCase( + id="legacy_coords_annular_region", + filter={ + "loc": { + "$nearSphere": [0, 0], + "$minDistance": km_to_radians(500), + "$maxDistance": km_to_radians(1000), + } + }, + doc=NEARSPHERE_LEGACY_DOCS, + expected=[ + {"_id": 3, "loc": [5, 5]}, + ], + msg="Should return docs in annular region with $nearSphere + legacy (radians)", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NEAR_2D_TESTS)) +def test_minDistance_near_legacy(collection, test): + """Verifies $minDistance with legacy coordinates (2d index).""" + collection.create_index([("loc", "2d")]) + 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(NEARSPHERE_LEGACY_COORDS_TESTS)) +def test_minDistance_nearSphere_legacy_coords(collection, test): + """Verifies $minDistance with $nearSphere and legacy coordinates (radians).""" + collection.create_index([("loc", "2dsphere")]) + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, msg=test.msg) + + +def test_minDistance_nearSphere_2d_annular(collection): + """Verifies $nearSphere + 2d index with both $minDistance and $maxDistance.""" + collection.create_index([("loc", "2d")]) + collection.insert_many( + [ + {"_id": 1, "loc": [0, 0]}, + {"_id": 2, "loc": [3, 0]}, + {"_id": 3, "loc": [50, 0]}, + ] + ) + result = execute_command( + collection, + { + "find": collection.name, + "filter": { + "loc": { + "$nearSphere": [0, 0], + "$minDistance": math.radians(1), + "$maxDistance": math.radians(10), + } + }, + }, + ) + assertSuccess( + result, + [{"_id": 2, "loc": [3, 0]}], + msg="Should return docs in annular region with $nearSphere + 2d (radians)", + ) diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index adbc5c20..71221b37 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -92,6 +92,9 @@ MAP_MISSING_INPUT_ERROR = 16880 MAP_MISSING_IN_ERROR = 16882 MAP_INPUT_NOT_ARRAY_ERROR = 16883 +LEGACY_MIN_DISTANCE_NOT_NUMBER_ERROR = 16893 +LEGACY_MIN_DISTANCE_NON_NEGATIVE_ERROR = 16894 +LEGACY_MAX_DISTANCE_NOT_NUMBER_ERROR = 16895 OUT_ARGUMENT_TYPE_ERROR = 16990 ALL_ELEMENTS_TRUE_NON_ARRAY_ERROR = 17040 ANY_ELEMENTS_TRUE_NON_ARRAY_ERROR = 17041