diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_argument_handling.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_argument_handling.py new file mode 100644 index 00000000..eb6b6dae --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_argument_handling.py @@ -0,0 +1,505 @@ +""" +Tests for $geoWithin argument handling, geometry formats, null/missing fields, and document types. + +Field lookup / dotted-path tests live in test_geoWithin_field_lookup.py. +""" + +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 + +# Standard polygon for reuse in tests +POLYGON = { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + } +} + + +ARGUMENT_HANDLING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="geometry_polygon_single_ring", + filter={"loc": {"$geoWithin": POLYGON}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="$geometry GeoJSON Polygon single ring should return matching docs", + ), + QueryTestCase( + id="geometry_polygon_with_hole", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]], + [[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]], + ], + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [5, 5]}}], + msg="Polygon with hole should exclude points in hole", + ), + QueryTestCase( + id="geometry_multipolygon", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "MultiPolygon", + "coordinates": [ + [[[-5, -5], [5, -5], [5, 5], [-5, 5], [-5, -5]]], + [[[15, 15], [25, 15], [25, 25], [15, 25], [15, 15]]], + ], + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [20, 20]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [20, 20]}}, + ], + msg="MultiPolygon should match points in either polygon", + ), + QueryTestCase( + id="geometry_polygon_with_strictwinding_crs", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + "crs": { + "type": "name", + "properties": {"name": "urn:x-mongodb:crs:strictwinding:EPSG:4326"}, + }, + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Polygon with strictwinding CRS should behave like default for small polygon", + ), + QueryTestCase( + id="geometry_polygon_with_strictwinding_crs_clockwise", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [-10, 10], [10, 10], [10, -10], [-10, -10]]], + "crs": { + "type": "name", + "properties": {"name": "urn:x-mongodb:crs:strictwinding:EPSG:4326"}, + }, + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}], + msg="Clockwise polygon with strictwinding CRS should match complement", + ), + QueryTestCase( + id="geometry_polygon_with_epsg4326_crs", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + "crs": { + "type": "name", + "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}, + }, + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Polygon with standard CRS84 (EPSG:4326) CRS should match points inside", + ), +] + + +NULL_MISSING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="null_field_no_match", + filter={"loc": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "loc": None}, {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}], + expected=[{"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Null location field should not match", + ), + QueryTestCase( + id="missing_field_no_match", + filter={"loc": {"$geoWithin": POLYGON}}, + doc=[ + {"_id": 1, "other": "value"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[{"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Missing location field should not match", + ), +] + + +DOCUMENT_TYPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="multiple_points_within", + filter={"loc": {"$geoWithin": POLYGON}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [9, 9]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [9, 9]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [5, 5]}}, + ], + msg="All points within polygon should be returned", + ), + QueryTestCase( + id="array_of_geojson_points", + filter={"locs": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "locs": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "Point", "coordinates": [5, 5]}, + ], + }, + { + "_id": 2, + "locs": [ + {"type": "Point", "coordinates": [50, 50]}, + {"type": "Point", "coordinates": [60, 60]}, + ], + }, + ], + expected=[ + { + "_id": 1, + "locs": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "Point", "coordinates": [5, 5]}, + ], + } + ], + msg="Array of GeoJSON Points should match if any element is within", + ), + QueryTestCase( + id="array_of_legacy_coords", + filter={"locs": {"$geoWithin": {"$box": [[-10, -10], [10, 10]]}}}, + doc=[{"_id": 1, "locs": [[0, 0], [5, 5]]}, {"_id": 2, "locs": [[50, 50], [60, 60]]}], + expected=[{"_id": 1, "locs": [[0, 0], [5, 5]]}], + msg="Array of legacy coordinate pairs should match", + ), + QueryTestCase( + id="array_of_linestrings", + filter={"routes": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "routes": [ + {"type": "LineString", "coordinates": [[0, 0], [5, 5]]}, + {"type": "LineString", "coordinates": [[0, 0], [50, 50]]}, + ], + }, + { + "_id": 2, + "routes": [ + {"type": "LineString", "coordinates": [[50, 50], [60, 60]]}, + {"type": "LineString", "coordinates": [[0, 0], [50, 50]]}, + ], + }, + ], + expected=[ + { + "_id": 1, + "routes": [ + {"type": "LineString", "coordinates": [[0, 0], [5, 5]]}, + {"type": "LineString", "coordinates": [[0, 0], [50, 50]]}, + ], + } + ], + msg="Array of LineStrings should match if any element is entirely within", + ), + QueryTestCase( + id="array_of_polygons", + filter={"coverage_areas": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "coverage_areas": [ + { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + { + "type": "Polygon", + "coordinates": [[[-50, -50], [50, -50], [50, 50], [-50, 50], [-50, -50]]], + }, + ], + }, + { + "_id": 2, + "coverage_areas": [ + { + "type": "Polygon", + "coordinates": [[[20, 20], [30, 20], [30, 30], [20, 30], [20, 20]]], + }, + { + "type": "Polygon", + "coordinates": [[[40, 40], [50, 40], [50, 50], [40, 50], [40, 40]]], + }, + ], + }, + ], + expected=[ + { + "_id": 1, + "coverage_areas": [ + { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + { + "type": "Polygon", + "coordinates": [[[-50, -50], [50, -50], [50, 50], [-50, 50], [-50, -50]]], + }, + ], + } + ], + msg="Array of Polygons should match if any element is entirely within", + ), + QueryTestCase( + id="multiple_geospatial_fields", + filter={"home": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "home": {"type": "Point", "coordinates": [0, 0]}, + "work": {"type": "Point", "coordinates": [50, 50]}, + }, + { + "_id": 2, + "home": {"type": "Point", "coordinates": [50, 50]}, + "work": {"type": "Point", "coordinates": [0, 0]}, + }, + ], + expected=[ + { + "_id": 1, + "home": {"type": "Point", "coordinates": [0, 0]}, + "work": {"type": "Point", "coordinates": [50, 50]}, + } + ], + msg="Query on one geospatial field should not affect other fields", + ), + QueryTestCase( + id="linestring_entirely_within", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + {"_id": 1, "geo": {"type": "LineString", "coordinates": [[0, 0], [5, 5]]}}, + {"_id": 2, "geo": {"type": "LineString", "coordinates": [[0, 0], [50, 50]]}}, + ], + expected=[{"_id": 1, "geo": {"type": "LineString", "coordinates": [[0, 0], [5, 5]]}}], + msg="LineString entirely within should match", + ), + QueryTestCase( + id="polygon_entirely_within", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + }, + { + "_id": 2, + "geo": { + "type": "Polygon", + "coordinates": [[[-50, -50], [50, -50], [50, 50], [-50, 50], [-50, -50]]], + }, + }, + ], + expected=[ + { + "_id": 1, + "geo": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + }, + } + ], + msg="Polygon entirely within should match", + ), + QueryTestCase( + id="multipoint_all_within", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + {"_id": 1, "geo": {"type": "MultiPoint", "coordinates": [[0, 0], [5, 5], [-5, -5]]}}, + {"_id": 2, "geo": {"type": "MultiPoint", "coordinates": [[0, 0], [50, 50]]}}, + ], + expected=[ + {"_id": 1, "geo": {"type": "MultiPoint", "coordinates": [[0, 0], [5, 5], [-5, -5]]}} + ], + msg="MultiPoint should match only if all points are within", + ), + QueryTestCase( + id="multilinestring_all_within", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "MultiLineString", + "coordinates": [[[0, 0], [5, 5]], [[-5, -5], [3, 3]]], + }, + }, + { + "_id": 2, + "geo": { + "type": "MultiLineString", + "coordinates": [[[0, 0], [5, 5]], [[0, 0], [50, 50]]], + }, + }, + ], + expected=[ + { + "_id": 1, + "geo": { + "type": "MultiLineString", + "coordinates": [[[0, 0], [5, 5]], [[-5, -5], [3, 3]]], + }, + } + ], + msg="MultiLineString should match only if all lines are within", + ), + QueryTestCase( + id="multilinestring_partial_no_match", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "MultiLineString", + "coordinates": [[[0, 0], [5, 5]], [[0, 0], [50, 50]]], + }, + } + ], + expected=[], + msg="MultiLineString with one line outside should not match", + ), + QueryTestCase( + id="multipoint_partial_no_match", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + {"_id": 1, "geo": {"type": "MultiPoint", "coordinates": [[0, 0], [50, 50]]}}, + {"_id": 2, "geo": {"type": "MultiPoint", "coordinates": [[1, 1], [2, 2]]}}, + ], + expected=[{"_id": 2, "geo": {"type": "MultiPoint", "coordinates": [[1, 1], [2, 2]]}}], + msg="MultiPoint with one point outside should not match", + ), + QueryTestCase( + id="geometry_collection_all_within", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "LineString", "coordinates": [[1, 1], [2, 2]]}, + ], + }, + }, + { + "_id": 2, + "geo": { + "type": "GeometryCollection", + "geometries": [{"type": "Point", "coordinates": [50, 50]}], + }, + }, + ], + expected=[ + { + "_id": 1, + "geo": { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "LineString", "coordinates": [[1, 1], [2, 2]]}, + ], + }, + } + ], + msg="GeometryCollection with all sub-geometries within should match", + ), + QueryTestCase( + id="geometry_collection_partial_no_match", + filter={"geo": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "Point", "coordinates": [50, 50]}, + ], + }, + } + ], + expected=[], + msg="GeometryCollection with one sub-geometry outside should not match", + ), +] + + +ALL_TESTS = ARGUMENT_HANDLING_TESTS + NULL_MISSING_TESTS + DOCUMENT_TYPE_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_geoWithin_argument_handling(collection, test): + """Test $geoWithin argument handling, data type coverage, and document types.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_bson_type_validation.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_bson_type_validation.py new file mode 100644 index 00000000..9fa06d70 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_bson_type_validation.py @@ -0,0 +1,99 @@ +""" +Tests for $geoWithin BSON type validation of shape operator arguments. + +Verifies that each $geoWithin shape operator rejects invalid BSON types for its +value with expected error codes and accepts valid BSON types without error. +""" + +import pytest + +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 + +GEOWITHIN_PARAMS = [ + BsonTypeTestCase( + id="geometry", + msg="$geometry should reject non-object types", + keyword="$geometry", + valid_types=[BsonType.OBJECT], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + valid_inputs={ + BsonType.OBJECT: { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + } + }, + ), + BsonTypeTestCase( + id="box", + msg="$box should reject non-array types and empty array", + keyword="$box", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + valid_inputs={BsonType.ARRAY: [[-10, -10], [10, 10]]}, + ), + BsonTypeTestCase( + id="polygon", + msg="$polygon should reject non-array types and empty array", + keyword="$polygon", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + valid_inputs={BsonType.ARRAY: [[-10, -10], [10, -10], [10, 10], [-10, 10]]}, + ), + BsonTypeTestCase( + id="center", + msg="$center should reject non-array types and empty array", + keyword="$center", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + valid_inputs={BsonType.ARRAY: [[0, 0], 10]}, + ), + BsonTypeTestCase( + id="centerSphere", + msg="$centerSphere should reject non-array types and empty array", + keyword="$centerSphere", + valid_types=[BsonType.ARRAY], + default_error_code=BAD_VALUE_ERROR, + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + valid_inputs={BsonType.ARRAY: [[0, 0], 0.5]}, + ), +] + +TEST_CASES = generate_bson_rejection_test_cases(GEOWITHIN_PARAMS) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", + TEST_CASES, +) +def test_geoWithin_bson_type_rejected(collection, bson_type, sample_value, spec): + """Test $geoWithin shape operators reject invalid BSON types.""" + query_filter = {"loc": {"$geoWithin": {spec.keyword: sample_value}}} + result = execute_command(collection, {"find": collection.name, "filter": query_filter}) + assertFailureCode(result, spec.expected_code(bson_type), msg=spec.msg) + + +ACCEPTANCE_CASES = generate_bson_acceptance_test_cases(GEOWITHIN_PARAMS) + + +@pytest.mark.parametrize( + "bson_type,sample_value,spec", + ACCEPTANCE_CASES, +) +def test_geoWithin_bson_type_accepted(collection, bson_type, sample_value, spec): + """Test $geoWithin shape operators accept valid BSON types and return matching docs.""" + collection.insert_many(spec.expected) + query_filter = {"loc": {"$geoWithin": {spec.keyword: sample_value}}} + result = execute_command(collection, {"find": collection.name, "filter": query_filter}) + assertSuccess(result, spec.expected, msg=f"{spec.keyword} should accept {bson_type.value}") diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_centersphere_containment.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_centersphere_containment.py new file mode 100644 index 00000000..a676bc69 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_centersphere_containment.py @@ -0,0 +1,195 @@ +""" +Tests for $geoWithin $centerSphere containment of non-Point geometry types +(LineString, Polygon, MultiPolygon). +""" + +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 + +# Spherical cap centered at [0, 0] with radius ~111km (0.01 radians ≈ 0.57 degrees) +SMALL_CAP = {"$centerSphere": [[0, 0], 0.01]} + +# Larger cap centered at [0, 0] with radius ~1111km (0.1 radians ≈ 5.7 degrees) +LARGE_CAP = {"$centerSphere": [[0, 0], 0.1]} + + +CENTERSPHERE_LINESTRING_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="linestring_entirely_within_cap", + filter={"geo": {"$geoWithin": LARGE_CAP}}, + doc=[ + {"_id": 1, "geo": {"type": "LineString", "coordinates": [[0.1, 0.1], [0.2, 0.2]]}}, + {"_id": 2, "geo": {"type": "LineString", "coordinates": [[0, 0], [20, 20]]}}, + ], + expected=[ + {"_id": 1, "geo": {"type": "LineString", "coordinates": [[0.1, 0.1], [0.2, 0.2]]}} + ], + msg="LineString entirely within $centerSphere should match", + ), + QueryTestCase( + id="linestring_intersecting_cap_no_match", + filter={"geo": {"$geoWithin": SMALL_CAP}}, + doc=[{"_id": 1, "geo": {"type": "LineString", "coordinates": [[0, 0], [5, 5]]}}], + expected=[], + msg="LineString intersecting but not entirely within $centerSphere should not match", + ), + QueryTestCase( + id="linestring_outside_cap_no_match", + filter={"geo": {"$geoWithin": SMALL_CAP}}, + doc=[{"_id": 1, "geo": {"type": "LineString", "coordinates": [[10, 10], [11, 11]]}}], + expected=[], + msg="LineString entirely outside $centerSphere should not match", + ), +] + + +CENTERSPHERE_POLYGON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="polygon_entirely_within_cap", + filter={"geo": {"$geoWithin": LARGE_CAP}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "Polygon", + "coordinates": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2], [0.1, 0.1]]], + }, + }, + { + "_id": 2, + "geo": { + "type": "Polygon", + "coordinates": [[[20, 20], [21, 20], [21, 21], [20, 21], [20, 20]]], + }, + }, + ], + expected=[ + { + "_id": 1, + "geo": { + "type": "Polygon", + "coordinates": [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2], [0.1, 0.1]]], + }, + } + ], + msg="Polygon entirely within $centerSphere should match", + ), + QueryTestCase( + id="polygon_intersecting_cap_no_match", + filter={"geo": {"$geoWithin": SMALL_CAP}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "Polygon", + "coordinates": [[[0, 0], [5, 0], [5, 5], [0, 5], [0, 0]]], + }, + } + ], + expected=[], + msg="Polygon intersecting but not entirely within $centerSphere should not match", + ), + QueryTestCase( + id="polygon_outside_cap_no_match", + filter={"geo": {"$geoWithin": SMALL_CAP}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "Polygon", + "coordinates": [[[10, 10], [11, 10], [11, 11], [10, 11], [10, 10]]], + }, + } + ], + expected=[], + msg="Polygon entirely outside $centerSphere should not match", + ), +] + + +CENTERSPHERE_MULTIPOLYGON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="multipolygon_all_within_cap", + filter={"geo": {"$geoWithin": LARGE_CAP}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "MultiPolygon", + "coordinates": [ + [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2], [0.1, 0.1]]], + [[[0.3, 0.3], [0.4, 0.3], [0.4, 0.4], [0.3, 0.4], [0.3, 0.3]]], + ], + }, + }, + { + "_id": 2, + "geo": { + "type": "MultiPolygon", + "coordinates": [ + [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2], [0.1, 0.1]]], + [[[20, 20], [21, 20], [21, 21], [20, 21], [20, 20]]], + ], + }, + }, + ], + expected=[ + { + "_id": 1, + "geo": { + "type": "MultiPolygon", + "coordinates": [ + [[[0.1, 0.1], [0.2, 0.1], [0.2, 0.2], [0.1, 0.2], [0.1, 0.1]]], + [[[0.3, 0.3], [0.4, 0.3], [0.4, 0.4], [0.3, 0.4], [0.3, 0.3]]], + ], + }, + } + ], + msg="MultiPolygon with all polygons within $centerSphere should match", + ), + QueryTestCase( + id="multipolygon_one_outside_no_match", + filter={"geo": {"$geoWithin": SMALL_CAP}}, + doc=[ + { + "_id": 1, + "geo": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [0.001, 0.001], + [0.002, 0.001], + [0.002, 0.002], + [0.001, 0.002], + [0.001, 0.001], + ] + ], + [[[20, 20], [21, 20], [21, 21], [20, 21], [20, 20]]], + ], + }, + } + ], + expected=[], + msg="MultiPolygon with one polygon outside $centerSphere should not match", + ), +] + + +ALL_TESTS = ( + CENTERSPHERE_LINESTRING_TESTS + CENTERSPHERE_POLYGON_TESTS + CENTERSPHERE_MULTIPOLYGON_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_geoWithin_centersphere_containment(collection, test): + """Test $geoWithin $centerSphere containment of non-Point geometry types.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_errors.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_errors.py new file mode 100644 index 00000000..26d91a39 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_errors.py @@ -0,0 +1,366 @@ +""" +Tests for $geoWithin error cases — argument validation, coordinate validation, and invalid geometry. +""" + +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 +from documentdb_tests.framework.executor import execute_command +from documentdb_tests.framework.parametrize import pytest_params + +GEOJSON_ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="missing_geometry_and_shape", + filter={"loc": {"$geoWithin": {}}}, + error_code=BAD_VALUE_ERROR, + msg="Missing $geometry and no shape operator should error", + ), + QueryTestCase( + id="invalid_geometry_type_linestring", + filter={ + "loc": { + "$geoWithin": {"$geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}} + } + }, + error_code=BAD_VALUE_ERROR, + msg="Invalid geometry type LineString should error", + ), + QueryTestCase( + id="empty_coordinates", + filter={"loc": {"$geoWithin": {"$geometry": {"type": "Polygon", "coordinates": []}}}}, + error_code=BAD_VALUE_ERROR, + msg="Empty coordinates array should error", + ), + QueryTestCase( + id="non_array_coordinates", + filter={ + "loc": {"$geoWithin": {"$geometry": {"type": "Polygon", "coordinates": "invalid"}}} + }, + error_code=BAD_VALUE_ERROR, + msg="Non-array coordinates should error", + ), + QueryTestCase( + id="non_object_argument", + filter={"loc": {"$geoWithin": "invalid"}}, + error_code=BAD_VALUE_ERROR, + msg="Non-object argument should error", + ), + QueryTestCase( + id="null_argument", + filter={"loc": {"$geoWithin": None}}, + error_code=BAD_VALUE_ERROR, + msg="Null argument should error", + ), + QueryTestCase( + id="latitude_above_90", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 91], [1, 91], [1, 92], [0, 92], [0, 91]]], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Latitude > 90 should error", + ), + QueryTestCase( + id="self_intersecting_polygon", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [2, 2], [2, 0], [0, 2], [0, 0]]], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Self-intersecting polygon should error", + ), + QueryTestCase( + id="unclosed_polygon_ring", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1]]], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Unclosed polygon ring should error", + ), + QueryTestCase( + id="polygon_non_contained_hole", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]], + [[50, 50], [51, 50], [51, 51], [50, 51], [50, 50]], + ], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Polygon with non-contained hole should error", + ), + QueryTestCase( + id="multipolygon_non_contained_hole", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]], + [[50, 50], [51, 50], [51, 51], [50, 51], [50, 50]], + ] + ], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="MultiPolygon with non-contained hole should error", + ), + QueryTestCase( + id="missing_type_field", + filter={ + "loc": { + "$geoWithin": { + "$geometry": {"coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]} + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="$geometry without type field should error", + ), + QueryTestCase( + id="missing_coordinates_field", + filter={"loc": {"$geoWithin": {"$geometry": {"type": "Polygon"}}}}, + error_code=BAD_VALUE_ERROR, + msg="$geometry without coordinates field should error", + ), + QueryTestCase( + id="non_string_type_field", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": 123, + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="$geometry with non-string type field should error", + ), + QueryTestCase( + id="invalid_geometry_type_point", + filter={"loc": {"$geoWithin": {"$geometry": {"type": "Point", "coordinates": [0, 0]}}}}, + error_code=BAD_VALUE_ERROR, + msg="Invalid geometry type Point should error", + ), + QueryTestCase( + id="invalid_geometry_type_multipoint", + filter={ + "loc": { + "$geoWithin": {"$geometry": {"type": "MultiPoint", "coordinates": [[0, 0], [1, 1]]}} + } + }, + error_code=BAD_VALUE_ERROR, + msg="Invalid geometry type MultiPoint should error", + ), + QueryTestCase( + id="invalid_geometry_type_multilinestring", + filter={ + "loc": { + "$geoWithin": { + "$geometry": {"type": "MultiLineString", "coordinates": [[[0, 0], [1, 1]]]} + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Invalid geometry type MultiLineString should error", + ), + QueryTestCase( + id="geometry_value_numeric", + filter={"loc": {"$geoWithin": {"$geometry": 1}}}, + error_code=BAD_VALUE_ERROR, + msg="$geometry with numeric value should error", + ), + QueryTestCase( + id="geometry_value_empty_string", + filter={"loc": {"$geoWithin": {"$geometry": ""}}}, + error_code=BAD_VALUE_ERROR, + msg="$geometry with empty string value should error", + ), + QueryTestCase( + id="geometry_value_boolean", + filter={"loc": {"$geoWithin": {"$geometry": False}}}, + error_code=BAD_VALUE_ERROR, + msg="$geometry with boolean value should error", + ), + QueryTestCase( + id="geometry_value_empty_array", + filter={"loc": {"$geoWithin": {"$geometry": []}}}, + error_code=BAD_VALUE_ERROR, + msg="$geometry with empty array value should error", + ), + QueryTestCase( + id="geojson_without_geometry_wrapper", + filter={ + "loc": { + "$geoWithin": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="GeoJSON object directly in $geoWithin without $geometry wrapper should error", + ), +] + + +CRS_ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="invalid_crs_type", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + "crs": { + "type": "link", + "properties": {"name": "urn:x-mongodb:crs:strictwinding:EPSG:4326"}, + }, + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="crs.type other than 'name' should error", + ), + QueryTestCase( + id="missing_crs_type", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + "crs": { + "properties": {"name": "urn:x-mongodb:crs:strictwinding:EPSG:4326"} + }, + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="crs without type field should error", + ), + QueryTestCase( + id="missing_crs_properties", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + "crs": {"type": "name"}, + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="crs without properties field should error", + ), + QueryTestCase( + id="unknown_crs_name", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + "crs": {"type": "name", "properties": {"name": "urn:bogus:not-a-real-crs"}}, + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="Unknown crs.properties.name URN should error", + ), + QueryTestCase( + id="missing_crs_properties_name", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + "crs": {"type": "name", "properties": {}}, + } + } + } + }, + error_code=BAD_VALUE_ERROR, + msg="crs.properties without name field should error", + ), +] + + +LEGACY_ERROR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="negative_radius", + filter={"loc": {"$geoWithin": {"$centerSphere": [[0, 0], -1]}}}, + error_code=BAD_VALUE_ERROR, + msg="$centerSphere negative radius should error", + ), + QueryTestCase( + id="center_negative_radius", + filter={"loc": {"$geoWithin": {"$center": [[0, 0], -1]}}}, + error_code=BAD_VALUE_ERROR, + msg="$center with negative radius should error", + ), + QueryTestCase( + id="box_single_corner", + filter={"loc": {"$geoWithin": {"$box": [[0, 0]]}}}, + error_code=BAD_VALUE_ERROR, + msg="$box with only one corner should error", + ), + QueryTestCase( + id="polygon_two_points", + filter={"loc": {"$geoWithin": {"$polygon": [[0, 0], [10, 10]]}}}, + error_code=BAD_VALUE_ERROR, + msg="$polygon with fewer than 3 points should error", + ), +] + + +ALL_ERROR_TESTS = GEOJSON_ERROR_TESTS + CRS_ERROR_TESTS + LEGACY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_ERROR_TESTS)) +def test_geoWithin_errors(collection, test): + """Test $geoWithin rejects invalid arguments, coordinates, geometry, and shape parameters.""" + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertFailureCode(result, test.error_code) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_field_lookup.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_field_lookup.py new file mode 100644 index 00000000..a223220d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_field_lookup.py @@ -0,0 +1,171 @@ +""" +Tests for $geoWithin field lookup patterns. + +Covers dotted paths through embedded documents, arrays of embedded documents +with dotted paths, deeply nested paths, and non-existent / null intermediate paths. +""" + +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 + +POLYGON = { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + } +} + +POINT_INSIDE = {"type": "Point", "coordinates": [0, 0]} +POINT_OUTSIDE = {"type": "Point", "coordinates": [50, 50]} + + +DOTTED_PATH_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="nested_field_inside", + filter={"geo.loc": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "geo": {"loc": POINT_INSIDE}}, {"_id": 2, "geo": {"loc": POINT_OUTSIDE}}], + expected=[{"_id": 1, "geo": {"loc": POINT_INSIDE}}], + msg="Dotted path to nested geo field should match point inside", + ), + QueryTestCase( + id="nested_field_outside", + filter={"geo.loc": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "geo": {"loc": POINT_OUTSIDE}}], + expected=[], + msg="Dotted path to nested geo field outside polygon should not match", + ), + QueryTestCase( + id="deeply_nested_geojson_feature", + filter={"feature.properties.geometry.location": {"$geoWithin": POLYGON}}, + doc=[ + {"_id": 1, "feature": {"properties": {"geometry": {"location": POINT_INSIDE}}}}, + {"_id": 2, "feature": {"properties": {"geometry": {"location": POINT_OUTSIDE}}}}, + ], + expected=[{"_id": 1, "feature": {"properties": {"geometry": {"location": POINT_INSIDE}}}}], + msg="Deeply nested dotted path through GeoJSON-Feature-like schema should match", + ), + QueryTestCase( + id="null_parent_no_match", + filter={"geo.loc": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "geo": None}, {"_id": 2, "geo": {"loc": POINT_INSIDE}}], + expected=[{"_id": 2, "geo": {"loc": POINT_INSIDE}}], + msg="Null parent field should not match", + ), + QueryTestCase( + id="missing_intermediate_field", + filter={"geo.loc": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "geo": {"other": "value"}}, {"_id": 2, "geo": {"loc": POINT_INSIDE}}], + expected=[{"_id": 2, "geo": {"loc": POINT_INSIDE}}], + msg="Missing intermediate field (parent has no child) should not match", + ), + QueryTestCase( + id="non_object_intermediate", + filter={"address.geocode.location": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "address": {"geocode": "not_an_object"}}], + expected=[], + msg="Dotted path through non-object intermediate should not match", + ), + QueryTestCase( + id="nonexistent_top_field", + filter={"missing.loc": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "geo": {"loc": POINT_INSIDE}}], + expected=[], + msg="Dotted path with non-existent top-level field should not match", + ), + QueryTestCase( + id="dotted_path_intermediate_null", + filter={"address.geocode.location": {"$geoWithin": POLYGON}}, + doc=[{"_id": 1, "address": {"geocode": None}}], + expected=[], + msg="Dotted path where intermediate field is null should not match", + ), +] + + +ARRAY_OF_EMBEDDED_DOCS_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="array_of_objects_any_inside", + filter={"addresses.location": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "addresses": [ + {"label": "home", "location": POINT_INSIDE}, + {"label": "work", "location": POINT_OUTSIDE}, + ], + }, + { + "_id": 2, + "addresses": [ + {"label": "home", "location": {"type": "Point", "coordinates": [60, 60]}}, + {"label": "work", "location": POINT_OUTSIDE}, + ], + }, + ], + expected=[ + { + "_id": 1, + "addresses": [ + {"label": "home", "location": POINT_INSIDE}, + {"label": "work", "location": POINT_OUTSIDE}, + ], + } + ], + msg="Dotted path through array of objects matches if ANY element's geo is inside", + ), + QueryTestCase( + id="array_of_objects_none_inside", + filter={"addresses.location": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "addresses": [ + {"label": "home", "location": POINT_OUTSIDE}, + {"label": "work", "location": {"type": "Point", "coordinates": [60, 60]}}, + ], + } + ], + expected=[], + msg="Dotted path through array of objects with no inside element should not match", + ), + QueryTestCase( + id="trips_waypoints_location", + filter={"trips.start.location": {"$geoWithin": POLYGON}}, + doc=[ + { + "_id": 1, + "trips": [ + {"start": {"location": POINT_INSIDE}}, + {"start": {"location": POINT_OUTSIDE}}, + ], + } + ], + expected=[ + { + "_id": 1, + "trips": [ + {"start": {"location": POINT_INSIDE}}, + {"start": {"location": POINT_OUTSIDE}}, + ], + } + ], + msg="Dotted path traverses array then nested object correctly", + ), +] + + +ALL_TESTS = DOTTED_PATH_TESTS + ARRAY_OF_EMBEDDED_DOCS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_geoWithin_field_lookup(collection, test): + """Parametrized test for $geoWithin field lookup patterns.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_geojson_polygon.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_geojson_polygon.py new file mode 100644 index 00000000..9d5224a2 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_geojson_polygon.py @@ -0,0 +1,263 @@ +""" +Tests for $geoWithin GeoJSON polygon edge cases, big polygons, and meridian-crossing polygons. +""" + +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 + +# Big polygon covering most of the earth (> hemisphere) +BIG_POLYGON = { + "type": "Polygon", + "coordinates": [[[-170, -80], [170, -80], [170, 80], [-170, 80], [-170, -80]]], + "crs": {"type": "name", "properties": {"name": "urn:x-mongodb:crs:strictwinding:EPSG:4326"}}, +} + +# Complementary polygon (small area NOT covered by big polygon) +SMALL_COMPLEMENT = { + "type": "Polygon", + "coordinates": [[[-170, 80], [170, 80], [170, -80], [-170, -80], [-170, 80]]], + "crs": {"type": "name", "properties": {"name": "urn:x-mongodb:crs:strictwinding:EPSG:4326"}}, +} + + +POLYGON_EDGE_CASE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="correct_lon_lat_order", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[39, 4], [41, 4], [41, 6], [39, 6], [39, 4]]], + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [40, 5]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [40, 5]}}], + msg="Correct [longitude, latitude] order should return correct results", + ), + QueryTestCase( + id="swapped_lon_lat_should_not_match", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[39, 4], [41, 4], [41, 6], [39, 6], [39, 4]]], + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [5, 40]}}, + ], + expected=[], + msg="Point with swapped [lat, lon] order should NOT match the polygon", + ), + QueryTestCase( + id="point_at_null_island", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + } + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Point at [0, 0] (null island) should match", + ), + QueryTestCase( + id="point_at_extreme_coords", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[179, 88], [180, 88], [180, 90], [179, 90], [179, 88]]], + } + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [180, 89]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [180, 89]}}], + msg="Point at extreme coordinates [180, 89] should match", + ), + QueryTestCase( + id="very_small_polygon", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [ + [[0, 0], [0.0001, 0], [0.0001, 0.0001], [0, 0.0001], [0, 0]] + ], + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0.00001, 0.00001]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [1, 1]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0.00001, 0.00001]}}], + msg="Very small polygon should match point inside", + ), + QueryTestCase( + id="duplicate_consecutive_vertices", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-1, -1], [-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]], + } + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="Polygon with duplicate consecutive vertices should still match", + ), + QueryTestCase( + id="point_inside_close_to_boundary", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + } + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [9.999, 0]}}], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [9.999, 0]}}], + msg="Point very close to boundary (inside) should match", + ), + QueryTestCase( + id="point_outside_close_to_boundary", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + } + } + } + }, + doc=[{"_id": 1, "loc": {"type": "Point", "coordinates": [10.001, 0]}}], + expected=[], + msg="Point very close to boundary (outside) should not match", + ), + QueryTestCase( + id="polygon_sharing_edge", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [5, 5]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [15, 5]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [5, 5]}}], + msg="Only point inside polygon should match, not one sharing edge outside", + ), +] + + +BIG_POLYGON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="big_polygon_with_strictwinding", + filter={"loc": {"$geoWithin": {"$geometry": BIG_POLYGON}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [-50, -50]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [175, 85]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [-50, -50]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [175, 85]}}, + ], + msg="Big polygon (>hemisphere) with strictwinding CRS should cover most of earth", + ), + QueryTestCase( + id="reverse_winding_returns_complement", + filter={"loc": {"$geoWithin": {"$geometry": SMALL_COMPLEMENT}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [175, 85]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [179, 0]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [-179, 0]}}, + ], + expected=[ + {"_id": 3, "loc": {"type": "Point", "coordinates": [179, 0]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [-179, 0]}}, + ], + msg="Reversed winding with strictwinding CRS returns complement (antimeridian sliver)", + ), +] + + +MERIDIAN_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="polygon_crossing_antimeridian", + filter={ + "loc": { + "$geoWithin": { + "$geometry": { + "type": "Polygon", + "coordinates": [[[178, -2], [-178, -2], [-178, 2], [178, 2], [178, -2]]], + } + } + } + }, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [179, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [-179, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [179.5, 0.5]}}, + {"_id": 4, "loc": {"type": "Point", "coordinates": [0, 0]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [179, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [-179, 0]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [179.5, 0.5]}}, + ], + msg="Polygon crossing antimeridian should match points near dateline", + ), +] + + +ALL_TESTS = POLYGON_EDGE_CASE_TESTS + BIG_POLYGON_TESTS + MERIDIAN_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_geoWithin_polygon(collection, test): + """Test $geoWithin polygon edge cases, big polygons, and meridian-crossing polygons.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_legacy_shapes.py b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_legacy_shapes.py new file mode 100644 index 00000000..95e27a20 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/geospatial/geoWithin/test_geoWithin_legacy_shapes.py @@ -0,0 +1,98 @@ +""" +Tests for $geoWithin legacy shape operators ($box, $polygon, $center, $centerSphere). +""" + +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 + +LEGACY_SHAPE_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="box_points_inside", + filter={"loc": {"$geoWithin": {"$box": [[0, 0], [10, 10]]}}}, + doc=[{"_id": 1, "loc": [5, 5]}, {"_id": 2, "loc": [15, 15]}], + expected=[{"_id": 1, "loc": [5, 5]}], + msg="$box should match points inside", + ), + QueryTestCase( + id="polygon_points_inside", + filter={"loc": {"$geoWithin": {"$polygon": [[0, 0], [10, 0], [10, 10], [0, 10]]}}}, + doc=[{"_id": 1, "loc": [5, 5]}, {"_id": 2, "loc": [15, 15]}], + expected=[{"_id": 1, "loc": [5, 5]}], + msg="$polygon should match points inside", + ), + QueryTestCase( + id="center_points_within_radius", + filter={"loc": {"$geoWithin": {"$center": [[0, 0], 5]}}}, + doc=[{"_id": 1, "loc": [1, 1]}, {"_id": 2, "loc": [10, 10]}], + expected=[{"_id": 1, "loc": [1, 1]}], + msg="$center should match points within flat circle radius", + ), + QueryTestCase( + id="centersphere_points_within", + filter={"loc": {"$geoWithin": {"$centerSphere": [[0, 0], 0.01]}}}, + doc=[{"_id": 1, "loc": [0.1, 0.1]}, {"_id": 2, "loc": [10, 10]}], + expected=[{"_id": 1, "loc": [0.1, 0.1]}], + msg="$centerSphere should match points within spherical circle", + ), +] + + +# Flat operators ($box, $polygon, $center) accept legacy [x, y] pairs and +# GeoJSON Point documents (the Point's coordinates are used). Non-Point +# GeoJSON document types (LineString, Polygon, etc.) silently do not match. +FLAT_OPERATOR_GEOJSON_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="box_with_geojson_point_matches", + filter={"loc": {"$geoWithin": {"$box": [[-10, -10], [10, 10]]}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="$box should match GeoJSON Point inside the box", + ), + QueryTestCase( + id="box_with_geojson_linestring_no_match", + filter={"loc": {"$geoWithin": {"$box": [[-10, -10], [10, 10]]}}}, + doc=[{"_id": 1, "loc": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]}}], + expected=[], + msg="$box should silently not match non-Point GeoJSON document", + ), + QueryTestCase( + id="polygon_with_geojson_point_matches", + filter={"loc": {"$geoWithin": {"$polygon": [[-10, -10], [10, -10], [10, 10], [-10, 10]]}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="$polygon should match GeoJSON Point inside the polygon", + ), + QueryTestCase( + id="center_with_geojson_point_matches", + filter={"loc": {"$geoWithin": {"$center": [[0, 0], 5]}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}], + msg="$center should match GeoJSON Point inside the radius", + ), +] + + +ALL_TESTS = LEGACY_SHAPE_TESTS + FLAT_OPERATOR_GEOJSON_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_geoWithin_legacy_shapes(collection, test): + """Test $geoWithin legacy shape operators ($box, $polygon, $center, $centerSphere).""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/query/test_query_combination_geospatial_operators.py b/documentdb_tests/compatibility/tests/core/operator/query/test_query_combination_geospatial_operators.py new file mode 100644 index 00000000..72039bdb --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/query/test_query_combination_geospatial_operators.py @@ -0,0 +1,160 @@ +""" +Tests for combinations of geospatial query operators with logical ($and, $or, $not, $nor) +and array ($elemMatch) operators. +""" + +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 + +POLYGON = { + "$geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]], + } +} + +POLYGON2 = { + "$geometry": { + "type": "Polygon", + "coordinates": [[[20, 20], [30, 20], [30, 30], [20, 30], [20, 20]]], + } +} + + +LOGICAL_OPERATOR_TESTS: list[QueryTestCase] = [ + QueryTestCase( + id="and_geo_with_non_geo", + filter={"$and": [{"loc": {"$geoWithin": POLYGON}}, {"status": "active"}]}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "status": "active"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [5, 5]}, "status": "inactive"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}, "status": "active"}, + ], + expected=[{"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "status": "active"}], + msg="$and combining geo and non-geo filter should intersect results", + ), + QueryTestCase( + id="or_two_geo_queries", + filter={"$or": [{"loc": {"$geoWithin": POLYGON}}, {"loc": {"$geoWithin": POLYGON2}}]}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [25, 25]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [25, 25]}}, + ], + msg="$or combining two geo queries should union results", + ), + QueryTestCase( + id="or_geo_with_non_geo", + filter={"$or": [{"loc": {"$geoWithin": POLYGON}}, {"status": "active"}]}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "status": "inactive"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}, "status": "active"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [60, 60]}, "status": "inactive"}, + ], + expected=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "status": "inactive"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}, "status": "active"}, + ], + msg="$or combining geo and non-geo filter should union results", + ), + QueryTestCase( + id="nor_two_geo_queries", + filter={"$nor": [{"loc": {"$geoWithin": POLYGON}}, {"loc": {"$geoWithin": POLYGON2}}]}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [25, 25]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}, + ], + expected=[{"_id": 3, "loc": {"type": "Point", "coordinates": [50, 50]}}], + msg="$nor should return documents not matching any condition", + ), + QueryTestCase( + id="nor_geo_with_non_geo", + filter={"$nor": [{"loc": {"$geoWithin": POLYGON}}, {"status": "inactive"}]}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "status": "active"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}, "status": "active"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [60, 60]}, "status": "inactive"}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}, "status": "active"} + ], + msg="$nor combining geo and non-geo filter should exclude both", + ), + QueryTestCase( + id="elemMatch_geo", + filter={"locations": {"$elemMatch": {"$geoWithin": POLYGON}}}, + doc=[ + { + "_id": 1, + "locations": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "Point", "coordinates": [50, 50]}, + ], + }, + { + "_id": 2, + "locations": [ + {"type": "Point", "coordinates": [50, 50]}, + {"type": "Point", "coordinates": [60, 60]}, + ], + }, + ], + expected=[ + { + "_id": 1, + "locations": [ + {"type": "Point", "coordinates": [0, 0]}, + {"type": "Point", "coordinates": [50, 50]}, + ], + } + ], + msg="$elemMatch with $geoWithin should match if any array element is within", + ), + QueryTestCase( + id="not_geo", + filter={"loc": {"$not": {"$geoWithin": POLYGON}}}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [60, 60]}}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [60, 60]}}, + ], + msg="$not with $geoWithin should return documents NOT within the polygon", + ), + QueryTestCase( + id="and_negated_geo_with_non_geo", + filter={"$and": [{"loc": {"$not": {"$geoWithin": POLYGON}}}, {"name": "B"}]}, + doc=[ + {"_id": 1, "loc": {"type": "Point", "coordinates": [0, 0]}, "name": "A"}, + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}, "name": "B"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [60, 60]}, "name": "B"}, + ], + expected=[ + {"_id": 2, "loc": {"type": "Point", "coordinates": [50, 50]}, "name": "B"}, + {"_id": 3, "loc": {"type": "Point", "coordinates": [60, 60]}, "name": "B"}, + ], + msg="$and combining negated $geoWithin with equality match on non-geo field", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(LOGICAL_OPERATOR_TESTS)) +def test_geoWithin_logical_operators(collection, test): + """Test $geoWithin with logical operators.""" + collection.insert_many(test.doc) + result = execute_command(collection, {"find": collection.name, "filter": test.filter}) + assertSuccess(result, test.expected, ignore_doc_order=True)