From fa590b921417500759294750ce79d67a407bb8f8 Mon Sep 17 00:00:00 2001 From: "Victor [C] Tsang" Date: Wed, 27 May 2026 17:47:07 +0000 Subject: [PATCH] Added indexes type tests for geospatial Signed-off-by: Victor [C] Tsang --- .../core/indexes/types/geospatial/__init__.py | 0 .../test_geospatial_bson_type_validation.py | 109 ++++ .../geospatial/test_geospatial_creation.py | 468 ++++++++++++++++++ .../geospatial/test_geospatial_errors.py | 431 ++++++++++++++++ documentdb_tests/framework/error_codes.py | 2 + 5 files changed, 1010 insertions(+) create mode 100644 documentdb_tests/compatibility/tests/core/indexes/types/geospatial/__init__.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_bson_type_validation.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_creation.py create mode 100644 documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_errors.py diff --git a/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/__init__.py b/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_bson_type_validation.py new file mode 100644 index 00000000..a466ec2d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_bson_type_validation.py @@ -0,0 +1,109 @@ +"""Tests for geospatial-specific index option BSON type validation. + +Verifies that 2d-specific (min, max, bits) and 2dsphere-specific +(2dsphereIndexVersion) options reject invalid BSON types and accept valid ones. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.framework.assertions import assertFailureCode, assertNotError +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 CANNOT_CREATE_INDEX_ERROR, INVALID_OPTIONS_ERROR +from documentdb_tests.framework.executor import execute_command + +pytestmark = pytest.mark.index + +GEOSPATIAL_INDEX_PARAMS = [ + BsonTypeTestCase( + id="min", + msg="min should reject non-numeric types", + keyword="min", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + valid_inputs={ + BsonType.DOUBLE: -500.0, + BsonType.INT: -500, + BsonType.LONG: -500, + BsonType.DECIMAL: Decimal128("-500"), + }, + ), + BsonTypeTestCase( + id="max", + msg="max should reject non-numeric types", + keyword="max", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG, BsonType.DECIMAL], + valid_inputs={ + BsonType.DOUBLE: 500.0, + BsonType.INT: 500, + BsonType.LONG: 500, + BsonType.DECIMAL: Decimal128("500"), + }, + ), + BsonTypeTestCase( + id="bits", + msg="bits should reject non-numeric types", + keyword="bits", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG], + valid_inputs={BsonType.DOUBLE: 26.0, BsonType.INT: 26, BsonType.LONG: 26}, + error_code_overrides={BsonType.DECIMAL: INVALID_OPTIONS_ERROR}, + ), + BsonTypeTestCase( + id="2dsphereIndexVersion", + msg="2dsphereIndexVersion should reject non-numeric types", + keyword="2dsphereIndexVersion", + valid_types=[BsonType.DOUBLE, BsonType.INT, BsonType.LONG], + valid_inputs={BsonType.DOUBLE: 3.0, BsonType.INT: 3, BsonType.LONG: 3}, + error_code_overrides={BsonType.DECIMAL: CANNOT_CREATE_INDEX_ERROR}, + ), +] + +_INDEX_TYPE_FOR_KEYWORD = { + "min": "2d", + "max": "2d", + "bits": "2d", + "2dsphereIndexVersion": "2dsphere", +} + + +def _build_index(keyword, value): + """Build a createIndexes spec with the given option set to value.""" + key_type = _INDEX_TYPE_FOR_KEYWORD[keyword] + index = {"key": {"loc": key_type}, "name": "test_idx"} + index[keyword] = value + return index + + +REJECTION_CASES = generate_bson_rejection_test_cases(GEOSPATIAL_INDEX_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", REJECTION_CASES) +def test_geospatial_index_option_rejected(collection, bson_type, sample_value, spec): + """Test geospatial index creation rejects invalid BSON types for options.""" + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": [_build_index(spec.keyword, sample_value)]}, + ) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(GEOSPATIAL_INDEX_PARAMS) + + +@pytest.mark.parametrize("bson_type,sample_value,spec", ACCEPTANCE_CASES) +def test_geospatial_index_option_accepted(collection, bson_type, sample_value, spec): + """Test geospatial index creation accepts valid BSON types for options. + + Note: This is a type validation test, not a functional test. We only verify + the command does not error — we do not check listIndexes to confirm the index + was created with the correct option value. + """ + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": [_build_index(spec.keyword, sample_value)]}, + ) + assertNotError(result, msg=f"{spec.keyword} should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_creation.py b/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_creation.py new file mode 100644 index 00000000..538cfecf --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_creation.py @@ -0,0 +1,468 @@ +"""Tests for geospatial index creation, data validation, and sparse behavior. + +Validates 2dsphere/2d creation, compound indexes, GeoJSON types (including +Multi* and GeometryCollection), coordinate boundaries, null/missing handling, +always-sparse behavior, duplicate/conflicting index handling, and legacy formats. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, + index_created_response, +) +from documentdb_tests.framework.assertions import ( + assertFailureCode, + assertSuccess, + assertSuccessPartial, +) +from documentdb_tests.framework.error_codes import INDEX_KEY_SPECS_CONFLICT_ERROR +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +CREATION_SUCCESS_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="creation_2dsphere", + indexes=({"key": {"location": "2dsphere"}, "name": "loc_2dsphere"},), + msg="2dsphere creation succeeds", + ), + IndexTestCase( + id="creation_2d", + indexes=({"key": {"location": "2d"}, "name": "loc_2d"},), + msg="2d creation succeeds", + ), + IndexTestCase( + id="creation_compound_2dsphere", + indexes=({"key": {"location": "2dsphere", "name": 1}, "name": "loc_name"},), + msg="Compound 2dsphere succeeds", + ), + IndexTestCase( + id="creation_compound_2dsphere_second", + indexes=({"key": {"name": 1, "location": "2dsphere"}, "name": "name_loc"},), + msg="Compound 2dsphere in second position succeeds", + ), + IndexTestCase( + id="creation_compound_2d", + indexes=({"key": {"location": "2d", "category": 1}, "name": "loc_cat"},), + msg="Compound 2d succeeds", + ), + IndexTestCase( + id="creation_multiple_2dsphere_fields", + indexes=({"key": {"loc1": "2dsphere", "loc2": "2dsphere"}, "name": "multi_2ds"},), + msg="Multiple 2dsphere fields succeed", + ), + IndexTestCase( + id="creation_2d_custom_min_max", + indexes=( + {"key": {"loc": "2d"}, "name": "loc_custom", "min": -500, "max": 500}, + ), # custom coordinate range + msg="2d with custom range succeeds", + ), + IndexTestCase( + id="creation_hidden", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_hidden", "hidden": True},), + msg="Hidden geospatial succeeds", + ), + IndexTestCase( + id="creation_custom_name", + indexes=({"key": {"loc": "2dsphere"}, "name": "my_geo_idx"},), + msg="Custom name succeeds", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(CREATION_SUCCESS_TESTS)) +def test_geospatial_creation_success(collection, test): + """Test geospatial index creation success cases.""" + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + assertSuccessPartial(result, index_created_response(), test.msg) + + +VALID_GEOJSON_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="data_point", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=({"_id": 1, "loc": {"type": "Point", "coordinates": [-73.97, 40.77]}},), + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [-73.97, 40.77]}}], + msg="Point document inserted and queryable", + ), + IndexTestCase( + id="data_linestring", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=({"_id": 1, "loc": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}},), + expected=[{"_id": 1, "loc": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}}], + msg="LineString document inserted", + ), + IndexTestCase( + id="data_polygon", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=( + { + "_id": 1, + "loc": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + }, + ), + expected=[ + { + "_id": 1, + "loc": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + } + ], + msg="Polygon document inserted", + ), + IndexTestCase( + id="data_polygon_with_hole", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=( + { + "_id": 1, + "loc": { + "type": "Polygon", + "coordinates": [ + [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], + [[2, 2], [8, 2], [8, 8], [2, 8], [2, 2]], + ], + }, + }, + ), + expected=[ + { + "_id": 1, + "loc": { + "type": "Polygon", + "coordinates": [ + [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], + [[2, 2], [8, 2], [8, 8], [2, 8], [2, 2]], + ], + }, + } + ], + msg="Polygon with hole document inserted", + ), + IndexTestCase( + id="data_legacy_pair", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=({"_id": 1, "loc": [10, 20]},), + expected=[{"_id": 1, "loc": [10, 20]}], + msg="Legacy pair with 2dsphere works", + ), + IndexTestCase( + id="data_geojson_extra_fields", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0], "extra": "ignored"}},), + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0], "extra": "ignored"}}], + msg="GeoJSON with extra fields accepted", + ), + IndexTestCase( + id="data_point_with_altitude", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0, 100]}},), + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0, 100]}}], + msg="Point with 3 coordinates (altitude) accepted", + ), + IndexTestCase( + id="data_multipoint", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=({"_id": 1, "loc": {"type": "MultiPoint", "coordinates": [[0, 0], [1, 1]]}},), + expected=[{"_id": 1, "loc": {"type": "MultiPoint", "coordinates": [[0, 0], [1, 1]]}}], + msg="MultiPoint document inserted", + ), + IndexTestCase( + id="data_multilinestring", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=( + { + "_id": 1, + "loc": { + "type": "MultiLineString", + "coordinates": [[[0, 0], [1, 1]], [[2, 2], [3, 3]]], + }, + }, + ), + expected=[ + { + "_id": 1, + "loc": { + "type": "MultiLineString", + "coordinates": [[[0, 0], [1, 1]], [[2, 2], [3, 3]]], + }, + } + ], + msg="MultiLineString document inserted", + ), + IndexTestCase( + id="data_multipolygon", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=( + { + "_id": 1, + "loc": { + "type": "MultiPolygon", + "coordinates": [[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + }, + }, + ), + expected=[ + { + "_id": 1, + "loc": { + "type": "MultiPolygon", + "coordinates": [[[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]], + }, + } + ], + msg="MultiPolygon document inserted", + ), + IndexTestCase( + id="data_geometrycollection", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=( + { + "_id": 1, + "loc": { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + ], + }, + }, + ), + expected=[ + { + "_id": 1, + "loc": { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}, + ], + }, + } + ], + msg="GeometryCollection document inserted", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(VALID_GEOJSON_TESTS)) +def test_geospatial_2dsphere_valid_data(collection, test): + """Test 2dsphere index with valid GeoJSON types.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + collection.insert_many(list(test.doc)) + result = execute_command( + collection, {"find": collection.name, "hint": "loc_2ds", "sort": {"_id": 1}} + ) + assertSuccess(result, test.expected, msg=test.msg) + + +NULL_MISSING_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="null_field_succeeds", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": None}, + msg="Null in 2dsphere field should succeed", + ), + IndexTestCase( + id="missing_field_succeeds", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "other": "x"}, + msg="Missing 2dsphere field should succeed", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(NULL_MISSING_TESTS)) +def test_geospatial_2dsphere_null_missing(collection, test): + """Test 2dsphere index allows null/missing field documents.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"insert": collection.name, "documents": [test.input]}) + assertSuccessPartial(result, {"n": 1}, msg=test.msg) + + +SPARSE_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="sparse_2dsphere", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + doc=( + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "other": "no geo field"}, + ), + command_options={"hint": "loc_2ds"}, + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Sparse 2dsphere index excludes doc without geo field", + ), + IndexTestCase( + id="sparse_2d", + indexes=({"key": {"loc": "2d"}, "name": "loc_2d"},), + doc=( + {"_id": 1, "loc": [10, 20]}, + {"_id": 2, "other": "no geo field"}, + ), + command_options={"hint": "loc_2d"}, + expected=[{"_id": 1, "loc": [10, 20]}], + msg="Sparse 2d index excludes doc without geo field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(SPARSE_TESTS)) +def test_geospatial_sparse(collection, test): + """Test geospatial indexes are always sparse.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + collection.insert_many(list(test.doc)) + result = execute_command( + collection, + {"find": collection.name, "hint": test.command_options["hint"], "sort": {"_id": 1}}, + ) + assertSuccess(result, test.expected, msg=test.msg) + + +def test_geospatial_invalid_data_without_index_succeeds(collection): + """Test inserting invalid geo data succeeds when no geo index exists.""" + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1, "loc": "not valid geo"}]}, + ) + assertSuccessPartial(result, {"n": 1}, msg="Invalid geo data without index should succeed") + + +def test_geospatial_2dsphere_boundary_coordinates(collection): + """Test coordinates at valid boundaries succeed.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2ds"}], + }, + ) + collection.insert_many( + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [-180, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [180, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0, -90]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [0, 90]}}, + ] + ) + result = execute_command(collection, {"find": collection.name, "sort": {"_id": 1}}) + assertSuccess( + result, + [ + {"_id": 1, "loc": {"type": "Point", "coordinates": [-180, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [180, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [0, -90]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [0, 90]}}, + ], + msg="Boundary coordinates should all succeed", + ) + + +def test_geospatial_duplicate_index_noop(collection): + """Test creating the same index twice is a no-op.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "geo"}], + }, + ) + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "geo"}], + }, + ) + assertSuccessPartial( + result, + {"numIndexesBefore": 2, "numIndexesAfter": 2, "ok": 1.0}, + msg="Duplicate index creation should be a no-op", + ) + + +def test_geospatial_conflicting_index_name_fails(collection): + """Test creating an index with same name but different key fails.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "geo"}], + }, + ) + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2d"}, "name": "geo"}], + }, + ) + assertFailureCode( + result, INDEX_KEY_SPECS_CONFLICT_ERROR, msg="Conflicting index name should fail" + ) + + +def test_geospatial_2d_null_field_succeeds(collection): + """Test 2d index allows null field (sparse behavior).""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2d"}, "name": "loc_2d"}], + }, + ) + result = execute_command( + collection, {"insert": collection.name, "documents": [{"_id": 1, "loc": None}]} + ) + assertSuccessPartial(result, {"n": 1}, msg="Null in 2d field should succeed") + + +def test_geospatial_2d_missing_field_succeeds(collection): + """Test 2d index allows missing field (sparse behavior).""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2d"}, "name": "loc_2d"}], + }, + ) + result = execute_command( + collection, {"insert": collection.name, "documents": [{"_id": 1, "other": "x"}]} + ) + assertSuccessPartial(result, {"n": 1}, msg="Missing 2d field should succeed") + + +def test_geospatial_2d_legacy_embedded_doc(collection): + """Test 2d index accepts legacy embedded document format {lng, lat}.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2d"}, "name": "loc_2d"}], + }, + ) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1, "loc": {"lng": 10, "lat": 20}}]}, + ) + assertSuccessPartial(result, {"n": 1}, msg="Legacy {lng, lat} format accepted by 2d index") diff --git a/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_errors.py b/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_errors.py new file mode 100644 index 00000000..86f19f6d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/indexes/types/geospatial/test_geospatial_errors.py @@ -0,0 +1,431 @@ +"""Tests for geospatial index error cases. + +Validates invalid GeoJSON data rejection, invalid BSON types in geo fields, +polygon structural errors, 2d out-of-range rejection, missing index errors, +invalid option values, index build on invalid data, index on view, and +invalid aggregation pipeline usage. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.indexes.commands.utils.index_test_case import ( + IndexTestCase, +) +from documentdb_tests.framework.assertions import assertFailureCode, assertSuccessPartial +from documentdb_tests.framework.error_codes import ( + CANNOT_CREATE_INDEX_ERROR, + CANT_EXTRACT_GEO_KEYS_ERROR, + COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, + GEO_2D_OUT_OF_RANGE_ERROR, + GEO_NEAR_NOT_FIRST_STAGE_ERROR, + INDEX_NOT_FOUND_ERROR, + INVALID_OPTIONS_ERROR, + NO_QUERY_EXECUTION_PLANS_ERROR, +) +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +pytestmark = pytest.mark.index + + +INVALID_GEOJSON_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="invalid_geojson_type_string", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "InvalidType", "coordinates": [0, 0]}}, + msg="Invalid GeoJSON type should fail on insert", + ), + IndexTestCase( + id="invalid_point_wrong_coords_count", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "Point", "coordinates": [1]}}, + msg="Point with 1 coordinate should fail", + ), + IndexTestCase( + id="invalid_longitude_out_of_range", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "Point", "coordinates": [-181, 0]}}, + msg="Longitude -181 should fail", + ), + IndexTestCase( + id="invalid_latitude_out_of_range", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "Point", "coordinates": [0, 91]}}, + msg="Latitude 91 should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_GEOJSON_TESTS)) +def test_geospatial_invalid_data(collection, test): + """Test 2dsphere index rejects invalid GeoJSON data.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"insert": collection.name, "documents": [test.input]}) + assertSuccessPartial( + result, {"writeErrors": [{"code": CANT_EXTRACT_GEO_KEYS_ERROR}]}, msg=test.msg + ) + + +ADDITIONAL_INVALID_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="invalid_longitude_positive_out_of_range", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "Point", "coordinates": [181, 0]}}, + msg="Longitude +181 should fail", + ), + IndexTestCase( + id="invalid_latitude_negative_out_of_range", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "Point", "coordinates": [0, -91]}}, + msg="Latitude -91 should fail", + ), + IndexTestCase( + id="invalid_point_empty_coordinates", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "Point", "coordinates": []}}, + msg="Point with empty coordinates should fail", + ), + IndexTestCase( + id="invalid_bson_string_in_geo_field", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": "hello"}, + msg="String in geo field should fail", + ), + IndexTestCase( + id="invalid_bson_integer_in_geo_field", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": 42}, + msg="Integer in geo field should fail", + ), + IndexTestCase( + id="invalid_bson_boolean_in_geo_field", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": True}, + msg="Boolean in geo field should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ADDITIONAL_INVALID_TESTS)) +def test_geospatial_additional_invalid_data(collection, test): + """Test 2dsphere index rejects additional invalid data types and values.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"insert": collection.name, "documents": [test.input]}) + assertSuccessPartial( + result, {"writeErrors": [{"code": CANT_EXTRACT_GEO_KEYS_ERROR}]}, msg=test.msg + ) + + +def test_geospatial_near_without_index_fails(collection): + """Test $near without geospatial index fails.""" + 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="$near without geo index should fail" + ) + + +def test_geospatial_geonear_not_first_stage_fails(collection): + """Test $geoNear as non-first stage fails.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2ds"}], + }, + ) + collection.insert_one( + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "name": "origin"} + ) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + {"$match": {"name": "origin"}}, + { + "$geoNear": { + "near": {"type": "Point", "coordinates": [0, 0]}, + "distanceField": "dist", + "spherical": True, + } + }, + ], + "cursor": {}, + }, + ) + assertFailureCode(result, GEO_NEAR_NOT_FIRST_STAGE_ERROR, msg="$geoNear must be first stage") + + +def test_geospatial_polygon_not_closed_fails(collection): + """Test polygon with first point != last point fails on insert.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2ds"}], + }, + ) + result = execute_command( + collection, + { + "insert": collection.name, + "documents": [ + { + "_id": 1, + "loc": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1]]], + }, + } + ], + }, + ) + assertSuccessPartial( + result, + {"writeErrors": [{"code": CANT_EXTRACT_GEO_KEYS_ERROR}]}, + msg="Unclosed polygon should fail", + ) + + +def test_geospatial_polygon_too_few_points_fails(collection): + """Test polygon with fewer than 4 points fails on insert.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2ds"}], + }, + ) + result = execute_command( + collection, + { + "insert": collection.name, + "documents": [ + { + "_id": 1, + "loc": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [0, 0]]], + }, + } + ], + }, + ) + assertSuccessPartial( + result, + {"writeErrors": [{"code": CANT_EXTRACT_GEO_KEYS_ERROR}]}, + msg="Polygon with <4 points should fail", + ) + + +def test_geospatial_2d_out_of_range_fails(collection): + """Test 2d index rejects coordinates outside custom min/max bounds.""" + execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [ + {"key": {"loc": "2d"}, "name": "loc_2d", "min": -500, "max": 500} + ], # custom range + }, + ) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1, "loc": [600, 0]}]}, + ) + assertSuccessPartial( + result, + {"writeErrors": [{"code": GEO_2D_OUT_OF_RANGE_ERROR}]}, + msg="Coordinates outside min/max should fail", + ) + + +def test_geospatial_geonear_without_index_fails(collection): + """Test $geoNear aggregation without geospatial index fails.""" + collection.insert_one({"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}) + result = execute_command( + collection, + { + "aggregate": collection.name, + "pipeline": [ + { + "$geoNear": { + "near": {"type": "Point", "coordinates": [0, 0]}, + "distanceField": "dist", + "spherical": True, + } + } + ], + "cursor": {}, + }, + ) + assertFailureCode(result, INDEX_NOT_FOUND_ERROR, msg="$geoNear without geo index should fail") + + +INVALID_VERSION_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="2dsphereIndexVersion_zero", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds", "2dsphereIndexVersion": 0},), + msg="2dsphereIndexVersion=0 should fail", + ), + IndexTestCase( + id="2dsphereIndexVersion_too_high", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds", "2dsphereIndexVersion": 4},), + msg="2dsphereIndexVersion=4 should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_VERSION_TESTS)) +def test_geospatial_invalid_2dsphere_version(collection, test): + """Test 2dsphere index rejects invalid 2dsphereIndexVersion values.""" + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + assertFailureCode(result, CANNOT_CREATE_INDEX_ERROR, msg=test.msg) + + +INVALID_BITS_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="bits_zero", + indexes=({"key": {"loc": "2d"}, "name": "loc_2d", "bits": 0},), + msg="bits=0 should fail", + ), + IndexTestCase( + id="bits_negative", + indexes=({"key": {"loc": "2d"}, "name": "loc_2d", "bits": -1},), + msg="bits=-1 should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_BITS_TESTS)) +def test_geospatial_invalid_bits(collection, test): + """Test 2d index rejects invalid bits values.""" + result = execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + assertFailureCode(result, INVALID_OPTIONS_ERROR, msg=test.msg) + + +MISSING_GEOJSON_FIELD_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="missing_type_field", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"coordinates": [0, 0]}}, + msg="GeoJSON missing 'type' field should fail", + ), + IndexTestCase( + id="missing_coordinates_field", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "Point"}}, + msg="GeoJSON missing 'coordinates' field should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MISSING_GEOJSON_FIELD_TESTS)) +def test_geospatial_missing_geojson_fields(collection, test): + """Test 2dsphere index rejects GeoJSON with missing required fields.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"insert": collection.name, "documents": [test.input]}) + assertSuccessPartial( + result, {"writeErrors": [{"code": CANT_EXTRACT_GEO_KEYS_ERROR}]}, msg=test.msg + ) + + +ADDITIONAL_GEOJSON_ERROR_TESTS: list[IndexTestCase] = [ + IndexTestCase( + id="lowercase_type_point", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={"_id": 1, "loc": {"type": "point", "coordinates": [0, 0]}}, + msg="Lowercase 'point' type should fail (case-sensitive)", + ), + IndexTestCase( + id="multikey_array_of_geojson", + indexes=({"key": {"loc": "2dsphere"}, "name": "loc_2ds"},), + input={ + "_id": 1, + "loc": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "Point", "coordinates": [1, 1]}, + ], + }, + msg="Array of GeoJSON objects should fail", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ADDITIONAL_GEOJSON_ERROR_TESTS)) +def test_geospatial_additional_geojson_errors(collection, test): + """Test 2dsphere index rejects invalid GeoJSON structures.""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": list(test.indexes)}, + ) + result = execute_command(collection, {"insert": collection.name, "documents": [test.input]}) + assertSuccessPartial( + result, {"writeErrors": [{"code": CANT_EXTRACT_GEO_KEYS_ERROR}]}, msg=test.msg + ) + + +def test_geospatial_2d_default_range_out_of_bounds(collection): + """Test 2d index with default range rejects coordinates outside [-180, 180].""" + execute_command( + collection, + {"createIndexes": collection.name, "indexes": [{"key": {"loc": "2d"}, "name": "loc_2d"}]}, + ) + result = execute_command( + collection, + {"insert": collection.name, "documents": [{"_id": 1, "loc": [181, 0]}]}, + ) + assertSuccessPartial( + result, + {"writeErrors": [{"code": GEO_2D_OUT_OF_RANGE_ERROR}]}, + msg="181 exceeds default 2d range", + ) + + +def test_geospatial_index_build_on_invalid_data(collection): + """Test creating 2dsphere index fails when collection has invalid geo data.""" + collection.insert_one({"_id": 1, "loc": "not valid geo"}) + result = execute_command( + collection, + { + "createIndexes": collection.name, + "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2ds"}], + }, + ) + assertFailureCode( + result, CANT_EXTRACT_GEO_KEYS_ERROR, msg="Index build on invalid data should fail" + ) + + +def test_geospatial_index_on_view_fails(collection): + """Test creating geospatial index on a view fails.""" + db = collection.database + db.drop_collection("geo_view") + db.command({"create": "geo_view", "viewOn": collection.name, "pipeline": []}) + result = execute_command( + collection, + {"createIndexes": "geo_view", "indexes": [{"key": {"loc": "2dsphere"}, "name": "loc_2ds"}]}, + ) + assertFailureCode(result, COMMAND_NOT_SUPPORTED_ON_VIEW_ERROR, msg="Index on view should fail") diff --git a/documentdb_tests/framework/error_codes.py b/documentdb_tests/framework/error_codes.py index 93b61c7e..a7c57c54 100644 --- a/documentdb_tests/framework/error_codes.py +++ b/documentdb_tests/framework/error_codes.py @@ -41,6 +41,7 @@ EXPRESSION_NOT_OBJECT_ERROR = 10065 BSON_OBJECT_TOO_LARGE_ERROR = 10334 DUPLICATE_KEY_ERROR = 11000 +GEO_2D_OUT_OF_RANGE_ERROR = 13027 SORT_COMPOUND_KEY_LIMIT_ERROR = 13103 BSON_FIELD_NOT_BOOL_ERROR = 13111 INVALID_DB_NAME_ERROR = 13280 @@ -80,6 +81,7 @@ MODULO_NON_NUMERIC_ERROR = 16611 MORE_THAN_ONE_DATE_ERROR = 16612 CONCAT_TYPE_ERROR = 16702 +CANT_EXTRACT_GEO_KEYS_ERROR = 16755 HASHED_UNIQUE_NOT_SUPPORTED_ERROR = 16764 EMPTY_VARIABLE_NAME_ERROR = 16867 INVALID_DOLLAR_FIELD_PATH = 16872