diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_as_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_as_errors.py new file mode 100644 index 000000000..dd35ecb5d --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_as_errors.py @@ -0,0 +1,128 @@ +""" +Error tests for $map 'as' parameter. + +Tests that $map rejects non-string 'as' values (including empty string). +""" + +from datetime import datetime + +import pytest +from bson import Binary, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import FAILED_TO_PARSE_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_INFINITY, FLOAT_NAN + +# Property [Invalid As Type]: $map rejects non-string types for the as parameter. +INVALID_AS_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "type_int", + expression={"$map": {"input": [1, 2, 3], "as": 1, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject int as variable name", + ), + ExpressionTestCase( + "type_long", + expression={"$map": {"input": [1, 2, 3], "as": Int64(1), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Int64 as variable name", + ), + ExpressionTestCase( + "type_object", + expression={"$map": {"input": [1, 2, 3], "as": {"a": 1}, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject object as variable name", + ), + ExpressionTestCase( + "type_array", + expression={"$map": {"input": [1, 2, 3], "as": [1], "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject array as variable name", + ), + ExpressionTestCase( + "type_minkey", + expression={"$map": {"input": [1, 2, 3], "as": MinKey(), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject MinKey as variable name", + ), + ExpressionTestCase( + "type_maxkey", + expression={"$map": {"input": [1, 2, 3], "as": MaxKey(), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject MaxKey as variable name", + ), + ExpressionTestCase( + "type_bindata", + expression={"$map": {"input": [1, 2, 3], "as": Binary(b"\x00", 0), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Binary as variable name", + ), + ExpressionTestCase( + "type_objectid", + expression={"$map": {"input": [1, 2, 3], "as": ObjectId(), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject ObjectId as variable name", + ), + ExpressionTestCase( + "type_date", + expression={"$map": {"input": [1, 2, 3], "as": datetime(2026, 1, 1), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject datetime as variable name", + ), + ExpressionTestCase( + "type_timestamp", + expression={"$map": {"input": [1, 2, 3], "as": Timestamp(0, 0), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Timestamp as variable name", + ), + ExpressionTestCase( + "type_regex", + expression={"$map": {"input": [1, 2, 3], "as": Regex("pattern"), "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Regex as variable name", + ), + ExpressionTestCase( + "type_bool_true", + expression={"$map": {"input": [1, 2, 3], "as": True, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject boolean as variable name", + ), + ExpressionTestCase( + "type_null", + expression={"$map": {"input": [1, 2, 3], "as": None, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject null as variable name", + ), + ExpressionTestCase( + "type_empty_string", + expression={"$map": {"input": [1, 2, 3], "as": "", "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject empty string as variable name", + ), + ExpressionTestCase( + "type_nan", + expression={"$map": {"input": [1, 2, 3], "as": FLOAT_NAN, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject NaN as variable name", + ), + ExpressionTestCase( + "type_infinity", + expression={"$map": {"input": [1, 2, 3], "as": FLOAT_INFINITY, "in": "$$this"}}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$map should reject Infinity as variable name", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INVALID_AS_TYPE_TESTS)) +def test_map_invalid_as(collection, test): + """Test $map with invalid 'as' parameter values.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_bson_types.py new file mode 100644 index 000000000..70781de24 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_bson_types.py @@ -0,0 +1,312 @@ +""" +BSON type element preservation tests for $map expression. + +Tests that various BSON types are preserved when mapping over arrays, +including special numeric values and boundary values. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TRAILING_ZERO, + DECIMAL128_TWO_AND_HALF, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Type Preservation]: $map preserves each element's BSON type. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Int64(1), Int64(2), Int64(3)]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$map should preserve Int64 values", + ), + ExpressionTestCase( + "decimal128_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]}, + expected=[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF], + msg="$map should preserve Decimal128 values", + ), + ExpressionTestCase( + "datetime_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ], + msg="$map should preserve datetime values", + ), + ExpressionTestCase( + "objectid_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")], + msg="$map should preserve ObjectId values", + ), + ExpressionTestCase( + "binary_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Binary(b"\x01", 0), Binary(b"\x02", 0)]}, + expected=[b"\x01", b"\x02"], + msg="$map should preserve Binary values", + ), + ExpressionTestCase( + "binary_subtype_preservation", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Binary(b"\x01", 128), Binary(b"\x02", 128)]}, + expected=[Binary(b"\x01", 128), Binary(b"\x02", 128)], + msg="$map should preserve Binary subtype", + ), + ExpressionTestCase( + "regex_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Regex("^a", "i"), Regex("^b", "i")]}, + expected=[Regex("^a", "i"), Regex("^b", "i")], + msg="$map should preserve Regex values", + ), + ExpressionTestCase( + "timestamp_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [Timestamp(1, 0), Timestamp(2, 0)]}, + expected=[Timestamp(1, 0), Timestamp(2, 0)], + msg="$map should preserve Timestamp values", + ), + ExpressionTestCase( + "minkey_maxkey", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [MinKey(), MaxKey()]}, + expected=[MinKey(), MaxKey()], + msg="$map should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + "uuid_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={ + "arr": [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + }, + expected=[ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ], + msg="$map should preserve UUID binary values", + ), +] + +# Property [Mixed Types]: $map processes arrays with mixed BSON types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_types", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None, MinKey()]}, + expected=[1, "two", Int64(3), Decimal128("4"), True, None, MinKey()], + msg="$map should preserve mixed BSON types via identity", + ), + ExpressionTestCase( + "mixed_dates_and_ids", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ] + }, + expected=[ + datetime(2024, 1, 1, tzinfo=timezone.utc), + ObjectId("000000000000000000000001"), + Timestamp(1, 0), + ], + msg="$map should preserve dates, ObjectIds, timestamps", + ), +] + +# Property [Special Numerics]: $map preserves NaN, Infinity, and boundary values. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]}, + expected=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + msg="$map should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]}, + expected=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + msg="$map should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "boundary_values", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX]}, + expected=[INT32_MIN, INT32_MAX, INT64_MIN, INT64_MAX], + msg="$map should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO]}, + expected=[DOUBLE_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + msg="$map should preserve negative zero values", + ), +] + +# Property [Decimal128 Precision]: $map preserves Decimal128 trailing zeros and precision. +DECIMAL128_PRECISION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "decimal128_trailing_zeros", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")]}, + expected=[DECIMAL128_TRAILING_ZERO, Decimal128("1.00"), Decimal128("1.000")], + msg="$map decimal128 trailing zeros preserved", + ), + ExpressionTestCase( + "decimal128_nan", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [DECIMAL128_NAN, Decimal128("1")]}, + expected=[DECIMAL128_NAN, Decimal128("1")], + msg="$map decimal128 NaN preserved", + ), +] + +# Property [Type Transform]: $map transforms elements using type-specific operations. +BSON_TRANSFORM_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiply_int64", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", Int64(2)]}}}, + doc={"arr": [Int64(10), Int64(20), Int64(30)]}, + expected=[Int64(20), Int64(40), Int64(60)], + msg="$multiply on Int64 should preserve Int64 type", + ), + ExpressionTestCase( + "add_decimal128", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", Decimal128("0.1")]}}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF, Decimal128("3.5")]}, + expected=[Decimal128("1.6"), Decimal128("2.6"), Decimal128("3.6")], + msg="$add on Decimal128 should preserve precision", + ), + ExpressionTestCase( + "type_of_mixed_bson", + expression={"$map": {"input": "$arr", "in": {"$type": "$$this"}}}, + doc={"arr": [1, "two", Int64(3), Decimal128("4"), True, None]}, + expected=["int", "string", "long", "decimal", "bool", "null"], + msg="$type on mixed BSON types", + ), + ExpressionTestCase( + "dateToString_datetime", + expression={ + "$map": { + "input": "$arr", + "in": {"$dateToString": {"format": "%Y-%m-%d", "date": "$$this"}}, + } + }, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 15, tzinfo=timezone.utc), + ] + }, + expected=["2024-01-01", "2024-06-15"], + msg="$dateToString on datetime array", + ), + ExpressionTestCase( + "toString_objectid", + expression={"$map": {"input": "$arr", "in": {"$toString": "$$this"}}}, + doc={"arr": [ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]}, + expected=["000000000000000000000001", "000000000000000000000002"], + msg="$toString on ObjectId array", + ), + ExpressionTestCase( + "toLong_int_values", + expression={"$map": {"input": "$arr", "in": {"$toLong": "$$this"}}}, + doc={"arr": [1, 2, 3]}, + expected=[Int64(1), Int64(2), Int64(3)], + msg="$toLong converts int to Int64", + ), + ExpressionTestCase( + "toDouble_decimal128", + expression={"$map": {"input": "$arr", "in": {"$toDouble": "$$this"}}}, + doc={"arr": [DECIMAL128_ONE_AND_HALF, Decimal128("2.0")]}, + expected=[1.5, 2.0], + msg="$toDouble on Decimal128 array", + ), + ExpressionTestCase( + "concat_strings", + expression={"$map": {"input": "$arr", "in": {"$concat": ["$$this", "!"]}}}, + doc={"arr": ["hello", "world"]}, + expected=["hello!", "world!"], + msg="$concat on string array", + ), + ExpressionTestCase( + "add_millis_to_datetime", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 86400000]}}}, + doc={ + "arr": [ + datetime(2024, 1, 1, tzinfo=timezone.utc), + datetime(2024, 6, 1, tzinfo=timezone.utc), + ] + }, + expected=[ + datetime(2024, 1, 2, tzinfo=timezone.utc), + datetime(2024, 6, 2, tzinfo=timezone.utc), + ], + msg="$map add one day in millis to datetime array", + ), + ExpressionTestCase( + "subtract_int64", + expression={"$map": {"input": "$arr", "in": {"$subtract": ["$$this", Int64(50)]}}}, + doc={"arr": [Int64(100), Int64(200), Int64(300)]}, + expected=[Int64(50), Int64(150), Int64(250)], + msg="$subtract on Int64 array", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + DECIMAL128_PRECISION_TESTS + + BSON_TRANSFORM_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_map_bson_type_preservation(collection, test): + """Test $map preserves BSON types and handles type-specific transforms.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result(result, expected=test.expected, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_core_behavior.py new file mode 100644 index 000000000..49c823396 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_core_behavior.py @@ -0,0 +1,312 @@ +""" +Core behavior tests for $map expression. + +Tests basic mapping, identity transforms, nested arrays, null propagation, +empty arrays, various in expressions, default and custom 'as' variable, +and large arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Transform]: $map applies an expression to each element. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "multiply_each", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": [1, 2, 3]}, + expected=[2, 4, 6], + msg="$map should multiply each element by 2", + ), + ExpressionTestCase( + "add_each", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 5]}}}, + doc={"arr": [10, 20, 30]}, + expected=[15, 25, 35], + msg="$map should add 5 to each element", + ), + ExpressionTestCase( + "identity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [1, 2, 3]}, + expected=[1, 2, 3], + msg="$map identity transform should return same array", + ), + ExpressionTestCase( + "constant_in", + expression={"$map": {"input": "$arr", "in": 13}}, + doc={"arr": [1, 2, 3]}, + expected=[13, 13, 13], + msg="$map constant in expression should repeat for each element", + ), + ExpressionTestCase( + "string_elements", + expression={"$map": {"input": "$arr", "in": {"$toUpper": "$$this"}}}, + doc={"arr": ["a", "b", "c"]}, + expected=["A", "B", "C"], + msg="$map should uppercase each string element", + ), + ExpressionTestCase( + "bool_elements", + expression={"$map": {"input": "$arr", "in": {"$not": "$$this"}}}, + doc={"arr": [True, False, True]}, + expected=[False, True, False], + msg="$map should negate each boolean element", + ), + ExpressionTestCase( + "single_element", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 1]}}}, + doc={"arr": [42]}, + expected=[43], + msg="$map should map single element", + ), + ExpressionTestCase( + "empty_array", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": []}, + expected=[], + msg="$map should return empty array for empty input", + ), + ExpressionTestCase( + "null_input", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": None}, + expected=None, + msg="$map should return null when input is null", + ), + ExpressionTestCase( + "custom_as_var", + expression={"$map": {"input": "$arr", "as": "val", "in": {"$multiply": ["$$val", 3]}}}, + doc={"arr": [1, 2, 3]}, + expected=[3, 6, 9], + msg="$map should use custom 'as' variable name", + ), +] + +# Property [Nested Arrays]: $map operates on nested array structures. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_arrays_identity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [[1, 2], [3, 4]]}, + expected=[[1, 2], [3, 4]], + msg="$map should preserve nested arrays", + ), + ExpressionTestCase( + "nested_arrays_size", + expression={"$map": {"input": "$arr", "in": {"$size": "$$this"}}}, + doc={"arr": [[1, 2], [3, 4, 5], []]}, + expected=[2, 3, 0], + msg="$map should compute size of each subarray", + ), + ExpressionTestCase( + "extract_field_from_objects", + expression={"$map": {"input": "$arr", "in": "$$this.a"}}, + doc={"arr": [{"a": 1, "b": 2}, {"a": 3, "b": 4}]}, + expected=[1, 3], + msg="$map should extract field from each object element", + ), +] + +# Property [Null Elements]: $map preserves null elements within the array. +NULL_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_elements_identity", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": [None, 1, None]}, + expected=[None, 1, None], + msg="$map should preserve null elements", + ), + ExpressionTestCase( + "null_elements_ifnull", + expression={"$map": {"input": "$arr", "in": {"$ifNull": ["$$this", 0]}}}, + doc={"arr": [None, 1, None]}, + expected=[0, 1, 0], + msg="$map should replace null elements with $ifNull", + ), +] + +# Property [Element Wrapping]: $map wraps each element in the specified structure. +WRAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "wrap_in_array", + expression={"$map": {"input": "$arr", "in": ["$$this"]}}, + doc={"arr": [1, 2, 3]}, + expected=[[1], [2], [3]], + msg="$map should wrap each element in an array", + ), + ExpressionTestCase( + "wrap_in_object", + expression={"$map": {"input": "$arr", "in": {"val": "$$this"}}}, + doc={"arr": [1, 2, 3]}, + expected=[{"val": 1}, {"val": 2}, {"val": 3}], + msg="$map should wrap each element in an object", + ), +] + +# Property [Type Conversion]: $map applies type conversion expressions to each element. +TYPE_CONVERSION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "to_string", + expression={"$map": {"input": "$arr", "in": {"$toString": "$$this"}}}, + doc={"arr": [1, 2, 3]}, + expected=["1", "2", "3"], + msg="$map should convert each element to string", + ), + ExpressionTestCase( + "type_of_each", + expression={"$map": {"input": "$arr", "in": {"$type": "$$this"}}}, + doc={"arr": [1, "two", True, None]}, + expected=["int", "string", "bool", "null"], + msg="$map should return type of each element", + ), +] + +# Property [Large Arrays]: $map handles large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_array_1000", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + doc={"arr": list(range(1000))}, + expected=[i * 2 for i in range(1000)], + msg="$map should map over 1000 elements", + ), +] + +# Property [Order Preservation]: $map preserves element order and duplicates. +ORDER_DUPLICATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "preserves_order", + expression={"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 10]}}}, + doc={"arr": [3, 1, 4, 1, 5, 9]}, + expected=[30, 10, 40, 10, 50, 90], + msg="$map should preserve element order", + ), + ExpressionTestCase( + "duplicate_elements", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 10]}}}, + doc={"arr": [1, 1, 1, 1]}, + expected=[11, 11, 11, 11], + msg="$map should process all duplicate elements", + ), + ExpressionTestCase( + "duplicate_nulls", + expression={"$map": {"input": "$arr", "in": {"$ifNull": ["$$this", 0]}}}, + doc={"arr": [None, None, None]}, + expected=[0, 0, 0], + msg="$map should process all null duplicates", + ), +] + + +# Property [Null Propagation]: $map returns null when input is null. +NULL_PROPAGATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_add_propagation", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 1]}}}, + doc={"arr": [None, None]}, + expected=[None, None], + msg="$map null + number should propagate null", + ), + ExpressionTestCase( + "null_in_mixed_add", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", 1]}}}, + doc={"arr": [1, 2, None, 4]}, + expected=[2, 3, None, 5], + msg="$map null element should propagate, others should add", + ), + ExpressionTestCase( + "null_input_ignores_in", + expression={"$map": {"input": "$arr", "in": {"$add": [2, 1]}}}, + doc={"arr": None}, + expected=None, + msg="$map null input returns null regardless of in expression", + ), + ExpressionTestCase( + "null_element_in_ignores", + expression={"$map": {"input": "$arr", "in": {"$add": [2, 1]}}}, + doc={"arr": [None]}, + expected=[3], + msg="$map expression ignoring element should still produce result", + ), +] + +# Property [Conditional Expressions]: $map supports conditional logic in the in expression. +CONDITIONAL_IN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "cond_classify", + expression={ + "$map": {"input": "$arr", "in": {"$cond": [{"$gte": ["$$this", 3]}, "high", "low"]}} + }, + doc={"arr": [1, 2, 3, 4]}, + expected=["low", "low", "high", "high"], + msg="$cond should classify elements", + ), + ExpressionTestCase( + "cond_isarray", + expression={ + "$map": { + "input": "$arr", + "in": {"$cond": [{"$isArray": "$$this"}, {"$size": "$$this"}, -1]}, + } + }, + doc={"arr": [[1, 2], "notArray", [3, 4, 5]]}, + expected=[2, -1, 3], + msg="$cond with $isArray should work", + ), +] + +# Property [Nested Expressions]: $map supports deeply nested operator expressions. +NESTED_IN_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_multiply_add", + expression={"$map": {"input": "$arr", "in": {"$multiply": [{"$add": ["$$this", 1]}, 2]}}}, + doc={"arr": [1, 2, 3]}, + expected=[4, 6, 8], + msg="$map nested expression should work", + ), + ExpressionTestCase( + "self_reference_double", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", "$$this"]}}}, + doc={"arr": [1, 2, 3]}, + expected=[2, 4, 6], + msg="$map self-reference should double each element", + ), + ExpressionTestCase( + "produce_nested_arrays", + expression={"$map": {"input": "$arr", "in": ["$$this", {"$multiply": ["$$this", 2]}]}}, + doc={"arr": [1, 2, 3]}, + expected=[[1, 2], [2, 4], [3, 6]], + msg="$map should produce nested arrays", + ), +] + +ALL_TESTS = ( + BASIC_TESTS + + NESTED_ARRAY_TESTS + + NULL_ELEMENT_TESTS + + WRAP_TESTS + + TYPE_CONVERSION_TESTS + + LARGE_ARRAY_TESTS + + ORDER_DUPLICATE_TESTS + + NULL_PROPAGATION_TESTS + + CONDITIONAL_IN_TESTS + + NESTED_IN_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_map_core_behavior(collection, test): + """Test $map core behavior: transforms, null propagation, conditionals.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_errors.py new file mode 100644 index 000000000..98440c698 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_errors.py @@ -0,0 +1,288 @@ +""" +Error tests for $map expression. + +Tests non-array input (all BSON types, special numeric values, boundary values), +structural errors (missing fields, unknown fields). +Note: $map propagates null — null input returns null (tested in core_behavior). +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + MAP_INPUT_NOT_ARRAY_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Non-Array Input]: $map rejects non-array input with all BSON types. +NOT_ARRAY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": "hello"}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject string input", + ), + ExpressionTestCase( + "int_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": 42}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject int input", + ), + ExpressionTestCase( + "negative_int_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": -42}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject negative int input", + ), + ExpressionTestCase( + "bool_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": True}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject bool input", + ), + ExpressionTestCase( + "object_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": {"a": 1}}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject object input", + ), + ExpressionTestCase( + "double_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": 3.14}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject double input", + ), + ExpressionTestCase( + "negative_double_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": -3.14}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject negative double input", + ), + ExpressionTestCase( + "decimal128_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Decimal128("1")}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject decimal128 input", + ), + ExpressionTestCase( + "int64_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Int64(1)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject int64 input", + ), + ExpressionTestCase( + "objectid_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": ObjectId()}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject objectid input", + ), + ExpressionTestCase( + "datetime_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject datetime input", + ), + ExpressionTestCase( + "binary_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Binary(b"x", 0)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject binary input", + ), + ExpressionTestCase( + "regex_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Regex("x")}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject regex input", + ), + ExpressionTestCase( + "code_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Code("function(){}")}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject javascript code input", + ), + ExpressionTestCase( + "maxkey_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": MaxKey()}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject maxkey input", + ), + ExpressionTestCase( + "minkey_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": MinKey()}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject minkey input", + ), + ExpressionTestCase( + "timestamp_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": Timestamp(0, 0)}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject timestamp input", + ), +] + +# Property [Special Numeric Input]: $map rejects special numeric values as input. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": FLOAT_NAN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject NaN input", + ), + ExpressionTestCase( + "inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": FLOAT_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Infinity input", + ), + ExpressionTestCase( + "neg_inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": FLOAT_NEGATIVE_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject -Infinity input", + ), + ExpressionTestCase( + "neg_zero_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DOUBLE_NEGATIVE_ZERO}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject negative zero input", + ), + ExpressionTestCase( + "decimal128_nan_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NAN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 NaN input", + ), + ExpressionTestCase( + "decimal128_neg_nan_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NEGATIVE_NAN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 -NaN input", + ), + ExpressionTestCase( + "decimal128_inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_inf_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NEGATIVE_INFINITY}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 -Infinity input", + ), + ExpressionTestCase( + "decimal128_neg_zero_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_NEGATIVE_ZERO}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject Decimal128 -0 input", + ), +] + +# Property [Boundary Input]: $map rejects numeric boundary values as input. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT32_MAX}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT32_MAX input", + ), + ExpressionTestCase( + "int32_min_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT32_MIN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT32_MIN input", + ), + ExpressionTestCase( + "int64_max_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT64_MAX}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT64_MAX input", + ), + ExpressionTestCase( + "int64_min_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": INT64_MIN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject INT64_MIN input", + ), + ExpressionTestCase( + "decimal128_max_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_MAX}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject DECIMAL128_MAX input", + ), + ExpressionTestCase( + "decimal128_min_input", + expression={"$map": {"input": "$arr", "in": "$$this"}}, + doc={"arr": DECIMAL128_MIN}, + error_code=MAP_INPUT_NOT_ARRAY_ERROR, + msg="$map should reject DECIMAL128_MIN input", + ), +] + +ALL_TESTS = NOT_ARRAY_ERROR_TESTS + SPECIAL_NUMERIC_ERROR_TESTS + BOUNDARY_ERROR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_map_non_array_input_error(collection, test): + """Test $map rejects non-array input with correct error code.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_expressions.py new file mode 100644 index 000000000..c983ce8c0 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_expressions.py @@ -0,0 +1,244 @@ +""" +Expression and field path tests for $map expression. + +Tests field path lookups, composite paths, system variables, +null/missing propagation via expressions, nested $map, and self-composition. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Field Path Resolution]: $map resolves nested and composite field paths. +# Property [Field Lookup]: $map resolves field paths in expressions. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$map": {"input": "$a.b", "in": {"$multiply": ["$$this", 2]}}}, + doc={"a": {"b": [1, 2, 3]}}, + expected=[2, 4, 6], + msg="$map should resolve nested field path", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$map": {"input": "$a.b.c", "in": "$$this"}}, + doc={"a": {"b": {"c": [10, 20]}}}, + expected=[10, 20], + msg="$map should resolve deeply nested field path", + ), + ExpressionTestCase( + "composite_array_path", + expression={"$map": {"input": "$a.b", "in": {"$multiply": ["$$this", 10]}}}, + doc={"a": [{"b": 1}, {"b": 2}, {"b": 3}]}, + expected=[10, 20, 30], + msg="$map composite array path should resolve to array", + ), + ExpressionTestCase( + "index_path_on_object_key", + expression={"$map": {"input": "$a.0.b", "in": "$$this"}}, + doc={"a": {"0": {"b": [1, 2, 3]}}}, + expected=[1, 2, 3], + msg="$map object key '0' resolves correctly", + ), + ExpressionTestCase( + "object_key_zero", + expression={"$map": {"input": "$a.0", "in": "$$this"}}, + doc={"a": {"0": [1, 2, 3]}}, + expected=[1, 2, 3], + msg="$a.0 resolves as field named '0' on object", + ), + ExpressionTestCase( + "access_outer_field", + expression={"$map": {"input": "$arr", "in": {"$add": ["$$this", "$val"]}}}, + doc={"arr": [1, 2, 3], "val": 100}, + expected=[101, 102, 103], + msg="$map should access outer document field in 'in' expression", + ), + ExpressionTestCase( + "array_expression_input", + expression={"$map": {"input": ["$x", "$y"], "in": {"$multiply": ["$$this", 2]}}}, + doc={"x": 1, "y": 2}, + expected=[2, 4], + msg="$map array expression with field refs resolved", + ), +] + +# Property [Variables]: $map works with $let and system variables. +LET_AND_VARIABLE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"arr": "$values"}, + "in": {"$map": {"input": "$$arr", "in": {"$add": ["$$this", 1]}}}, + } + }, + doc={"values": [1, 2, 3]}, + expected=[2, 3, 4], + msg="$map should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$map": {"input": "$$ROOT.values", "in": "$$this"}}, + doc={"_id": 1, "values": [10, 20]}, + expected=[10, 20], + msg="$map should work with $$ROOT", + ), + ExpressionTestCase( + "current_variable", + expression={"$map": {"input": "$$CURRENT.values", "in": "$$this"}}, + doc={"_id": 2, "values": [10, 20]}, + expected=[10, 20], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Property [Null/Missing Fields]: $map handles null and missing field paths. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={"$map": {"input": "$nonexistent", "in": "$$this"}}, + doc={"other": 1}, + expected=None, + msg="$map missing field should return null", + ), + ExpressionTestCase( + "missing_input_type_is_null", + expression={"$type": {"$map": {"input": "$nonexistent", "in": "$$this"}}}, + doc={"x": 1}, + expected="null", + msg="$map missing field should produce null type", + ), + ExpressionTestCase( + "remove_variable", + expression={"$map": {"input": "$$REMOVE", "in": "$$this"}}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE propagates null", + ), + ExpressionTestCase( + "missing_field_in_expression", + expression={"$map": {"input": "$arr", "in": MISSING}}, + doc={"arr": [1, 2, 3]}, + expected=[None, None, None], + msg="$map missing field in 'in' should produce null for each element", + ), +] + +# Property [Nested Map]: $map can be nested inside another $map. +NESTED_MAP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_map", + expression={ + "$map": { + "input": "$arr", + "as": "inarr", + "in": { + "$map": { + "input": "$$inarr", + "as": "num", + "in": {"$multiply": ["$$num", 2]}, + } + }, + } + }, + doc={"arr": [[1, 2], [3, 4]]}, + expected=[[2, 4], [6, 8]], + msg="$map nested $map should process 2D array", + ), +] + + +# Property [Reduce Interaction]: $map output works with $reduce. +REDUCE_INTERACTION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "map_within_reduce", + expression={ + "$reduce": { + "input": [4, 5, 6], + "initialValue": [0], + "in": { + "$concatArrays": ["$$value", {"$map": {"input": [1, 2, 3], "in": "$$this"}}] + }, + } + }, + doc={"_placeholder": 1}, + expected=[0, 1, 2, 3, 1, 2, 3, 1, 2, 3], + msg="$map's $$this should reference $map elements, not $reduce's", + ), + ExpressionTestCase( + "reduce_within_map", + expression={ + "$map": { + "input": [100, 50], + "in": { + "$reduce": { + "input": [25, 25], + "initialValue": 1, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + } + }, + doc={"_placeholder": 1}, + expected=[51, 51], + msg="$reduce's $$value and $$this should be scoped to $reduce", + ), +] + +# Property [System Variables]: $map works with $$ROOT and $$REMOVE. +SYSTEM_VAR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "root_returns_full_doc", + expression={"$map": {"input": "$arr", "in": "$$ROOT"}}, + doc={"_id": 1, "arr": [1, 2]}, + expected=[{"_id": 1, "arr": [1, 2]}, {"_id": 1, "arr": [1, 2]}], + msg="$$ROOT should return root document for each element", + ), + ExpressionTestCase( + "root_field_access", + expression={"$map": {"input": "$arr", "in": "$$ROOT.name"}}, + doc={"_id": 1, "arr": [1, 2], "name": "test"}, + expected=["test", "test"], + msg="$$ROOT.field should access root field for each element", + ), + ExpressionTestCase( + "remove_in_cond_becomes_null", + expression={ + "$map": { + "input": [1, 10, 2, 20], + "in": {"$cond": [{"$gt": ["$$this", 5]}, "$$this", "$$REMOVE"]}, + } + }, + doc={"_placeholder": 1}, + expected=[None, 10, None, 20], + msg="$$REMOVE as array element becomes null, does not remove element", + ), +] + + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + LET_AND_VARIABLE_TESTS + + NULL_MISSING_EXPR_TESTS + + NESTED_MAP_TESTS + + REDUCE_INTERACTION_TESTS + + SYSTEM_VAR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_map_field_paths_and_variables(collection, test): + """Test $map with field paths, $let, system variables, and nested composition.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_structure_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_structure_errors.py new file mode 100644 index 000000000..9e34ee16c --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_map_structure_errors.py @@ -0,0 +1,110 @@ +""" +Structural error tests for $map expression. + +Tests invalid $map argument structure: non-object argument, unknown/misspelled fields, +missing required fields (input, in), and runtime errors in the 'in' expression. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_NON_OBJECT_ARG_ERROR, + MAP_MISSING_IN_ERROR, + MAP_MISSING_INPUT_ERROR, + MAP_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Object Argument]: $map rejects non-object arguments. +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_arg", + expression={"$map": None}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map null arg should error", + ), + ExpressionTestCase( + "int_arg", + expression={"$map": 1}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map int arg should error", + ), + ExpressionTestCase( + "string_arg", + expression={"$map": "string"}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map string arg should error", + ), + ExpressionTestCase( + "array_arg", + expression={"$map": [1, 2]}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map array arg should error", + ), + ExpressionTestCase( + "bool_arg", + expression={"$map": True}, + error_code=EXPRESSION_NON_OBJECT_ARG_ERROR, + msg="$map bool arg should error", + ), +] + +# Property [Unknown Fields]: $map rejects unknown fields in the argument. +UNKNOWN_FIELD_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "extra_unknown", + expression={"$map": {"input": [1], "in": "$$this", "unknown": 1}}, + error_code=MAP_UNKNOWN_FIELD_ERROR, + msg="$map extra unknown field should error", + ), + ExpressionTestCase( + "misspelled_inputs", + expression={"$map": {"inputs": [1], "in": "$$this"}}, + error_code=MAP_UNKNOWN_FIELD_ERROR, + msg="$map misspelled 'inputs' should error", + ), +] + +# Property [Required Fields]: $map requires the input and in fields. +MISSING_REQUIRED_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_input", + expression={"$map": {"in": "$$this"}}, + error_code=MAP_MISSING_INPUT_ERROR, + msg="$map missing input should error", + ), + ExpressionTestCase( + "missing_in", + expression={"$map": {"input": [1, 2, 3]}}, + error_code=MAP_MISSING_IN_ERROR, + msg="$map missing in should error", + ), + ExpressionTestCase( + "missing_in_with_as", + expression={"$map": {"input": [1, 2, 3], "as": "x"}}, + error_code=MAP_MISSING_IN_ERROR, + msg="$map missing in with as should error", + ), + ExpressionTestCase( + "empty_object", + expression={"$map": {}}, + error_code=MAP_MISSING_INPUT_ERROR, + msg="$map empty object should error", + ), +] + +ALL_STRUCTURE_TESTS = NON_OBJECT_ARG_TESTS + UNKNOWN_FIELD_TESTS + MISSING_REQUIRED_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_STRUCTURE_TESTS)) +def test_map_structure_error(collection, test): + """Test $map argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result(result, error_code=test.error_code, msg=test.msg) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_expression_map.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_map.py similarity index 92% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_expression_map.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_map.py index a5bf7ae58..d3ef08577 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_expression_map.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/map/test_smoke_map.py @@ -38,4 +38,4 @@ def test_smoke_expression_map(collection): ) expected = [{"_id": 1, "doubled": [2, 4, 6]}, {"_id": 2, "doubled": [8, 10, 12]}] - assertSuccess(result, expected, msg="Should support $map expression") + assertSuccess(result, expected, "Should support $map expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_boundary.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_boundary.py new file mode 100644 index 000000000..b50d89e2a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_boundary.py @@ -0,0 +1,87 @@ +""" +Boundary value tests for $range expression. + +Tests INT32 boundary value handling for start, end, step. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import INT32_MAX, INT32_MAX_MINUS_1, INT32_MIN + +# Property [INT32 Boundaries]: $range works at INT32 boundary values. +INT32_BOUNDARY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_eq", + doc={"start": INT32_MAX, "end": INT32_MAX}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range INT32_MAX == INT32_MAX should be empty", + ), + ExpressionTestCase( + "int32_min_eq", + doc={"start": INT32_MIN, "end": INT32_MIN}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range INT32_MIN == INT32_MIN should be empty", + ), + ExpressionTestCase( + "int32_max_minus1", + doc={"start": INT32_MAX_MINUS_1, "end": INT32_MAX}, + expression={"$range": ["$start", "$end"]}, + expected=[INT32_MAX_MINUS_1], + msg="$range INT32_MAX-1 to INT32_MAX should produce single element", + ), + ExpressionTestCase( + "int32_min_to_plus3", + doc={"start": INT32_MIN, "end": INT32_MIN + 3}, + expression={"$range": ["$start", "$end"]}, + expected=[INT32_MIN, INT32_MIN + 1, INT32_MIN + 2], + msg="$range INT32_MIN to INT32_MIN+3", + ), + ExpressionTestCase( + "step_int32_max", + doc={"start": 0, "end": INT32_MAX, "step": INT32_MAX}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0], + msg="$range step INT32_MAX should produce single element", + ), + ExpressionTestCase( + "near_int32_max", + doc={"start": INT32_MAX - 7, "end": INT32_MAX}, + expression={"$range": ["$start", "$end"]}, + expected=[ + INT32_MAX - 7, + INT32_MAX - 6, + INT32_MAX - 5, + INT32_MAX - 4, + INT32_MAX - 3, + INT32_MAX - 2, + INT32_MAX - 1, + ], + msg="$range near INT32_MAX range", + ), + ExpressionTestCase( + "cross_int32_boundary", + doc={"start": INT32_MIN + 1, "end": INT32_MAX, "step": INT32_MAX}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[INT32_MIN + 1, 0], + msg="$range cross INT32 boundary with large step", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(INT32_BOUNDARY_TESTS)) +def test_range_int32_boundary(collection, test): + """Test $range with INT32 boundary values for start, end, step.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_core_behavior.py new file mode 100644 index 000000000..df99838b3 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_core_behavior.py @@ -0,0 +1,226 @@ +""" +Core behavior tests for $range expression. + +Tests generating integer sequences with various start, end, step values, +negative steps (descending), and empty results. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Ascending]: $range generates ascending integer sequences. +BASIC_ASC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_to_five", + doc={"start": 0, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2, 3, 4], + msg="$range should generate 0..4", + ), + ExpressionTestCase( + "one_to_four", + doc={"start": 1, "end": 4}, + expression={"$range": ["$start", "$end"]}, + expected=[1, 2, 3], + msg="$range should generate 1..3", + ), + ExpressionTestCase( + "negative_range", + doc={"start": -5, "end": -1}, + expression={"$range": ["$start", "$end"]}, + expected=[-5, -4, -3, -2], + msg="$range should generate -5..-2", + ), + ExpressionTestCase( + "start_equals_end", + doc={"start": 5, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range should return empty when start equals end", + ), + ExpressionTestCase( + "start_greater_than_end", + doc={"start": 10, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range should return empty when start > end with default step", + ), +] + +# Property [Custom Step]: $range respects custom step values. +STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "step_two", + doc={"start": 0, "end": 10, "step": 2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4, 6, 8], + msg="$range should generate with step 2", + ), + ExpressionTestCase( + "step_three", + doc={"start": 0, "end": 10, "step": 3}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 3, 6, 9], + msg="$range should generate with step 3", + ), + ExpressionTestCase( + "step_five", + doc={"start": 0, "end": 20, "step": 5}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 5, 10, 15], + msg="$range should generate with step 5", + ), + ExpressionTestCase( + "step_one_explicit", + doc={"start": 0, "end": 3, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 1, 2], + msg="$range explicit step 1 same as default", + ), + ExpressionTestCase( + "step_overshoots", + doc={"start": 0, "end": 5, "step": 3}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 3], + msg="$range should stop when step overshoots end", + ), + ExpressionTestCase( + "step_exactly_reaches", + doc={"start": 0, "end": 6, "step": 2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range end is exclusive even when step exactly reaches it", + ), + ExpressionTestCase( + "start_nonzero", + doc={"start": 5, "end": 15}, + expression={"$range": ["$start", "$end"]}, + expected=[5, 6, 7, 8, 9, 10, 11, 12, 13, 14], + msg="$range should work with nonzero start", + ), + ExpressionTestCase( + "step_4", + doc={"start": 5, "end": 15, "step": 4}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[5, 9, 13], + msg="$range should work with step 4", + ), +] + +# Property [Negative Step]: $range generates descending sequences with negative step. +NEGATIVE_STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "descending_basic", + doc={"start": 5, "end": 0, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[5, 4, 3, 2, 1], + msg="$range should generate descending 5..1", + ), + ExpressionTestCase( + "descending_step_two", + doc={"start": 10, "end": 0, "step": -2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[10, 8, 6, 4, 2], + msg="$range should generate descending with step -2", + ), + ExpressionTestCase( + "descending_negative_range", + doc={"start": -1, "end": -5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[-1, -2, -3, -4], + msg="$range should generate descending in negative range", + ), + ExpressionTestCase( + "descending_start_equals_end", + doc={"start": 5, "end": 5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range should return empty when start equals end with negative step", + ), + ExpressionTestCase( + "descending_wrong_direction", + doc={"start": 0, "end": 5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range should return empty when step direction mismatches", + ), + ExpressionTestCase( + "descending_step_neg3", + doc={"start": 10, "end": 0, "step": -3}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[10, 7, 4, 1], + msg="$range should generate descending with step -3", + ), + ExpressionTestCase( + "descending_past_zero", + doc={"start": 5, "end": -1, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[5, 4, 3, 2, 1, 0], + msg="$range should descend past zero", + ), +] + +# Property [Empty Result]: $range returns empty when start/end/step produce no elements. +EMPTY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ascending_wrong_direction", + doc={"start": 5, "end": 0, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range should return empty when ascending step but start > end", + ), + ExpressionTestCase( + "zero_zero_pos_step", + doc={"start": 0, "end": 0, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range 0 to 0 step 1 should be empty", + ), + ExpressionTestCase( + "zero_zero_neg_step", + doc={"start": 0, "end": 0, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range 0 to 0 step -1 should be empty", + ), + ExpressionTestCase( + "neg_equal", + doc={"start": -1, "end": -1}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range -1 to -1 should be empty", + ), + ExpressionTestCase( + "large_equal", + doc={"start": 1000000, "end": 1000000}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range large equal start/end should be empty", + ), + ExpressionTestCase( + "neg_mismatch", + doc={"start": -1, "end": -5, "step": 1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[], + msg="$range negative range with positive step should be empty", + ), +] + +ALL_TESTS = BASIC_ASC_TESTS + STEP_TESTS + NEGATIVE_STEP_TESTS + EMPTY_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_core_behavior(collection, test): + """Test $range core behavior: ascending, steps, descending, empty results.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_expressions.py new file mode 100644 index 000000000..b39f4cc31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_expressions.py @@ -0,0 +1,156 @@ +""" +Expression and field path tests for $range expression. + +Tests field path lookups, composite paths, system variables, +and null/missing behavior. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + RANGE_END_NOT_NUMERIC_ERROR, + RANGE_START_NOT_INT32_ERROR, + RANGE_STEP_NOT_NUMERIC_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Field Path Resolution]: $range resolves nested and composite field paths. +# Property [Field Lookup]: $range resolves field paths in expressions. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$range": ["$a.start", "$a.end"]}, + doc={"a": {"start": 0, "end": 3}}, + expected=[0, 1, 2], + msg="$range should resolve nested field paths", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$range": ["$a.b.start", "$a.b.end"]}, + doc={"a": {"b": {"start": 1, "end": 4}}}, + expected=[1, 2, 3], + msg="$range should resolve deeply nested field paths", + ), + ExpressionTestCase( + "nested_expr_start_end", + expression={"$range": [{"$add": [1, 2]}, {"$multiply": [2, 5]}]}, + doc={"_placeholder": 1}, + expected=[3, 4, 5, 6, 7, 8, 9], + msg="$range should support nested expressions as start and end", + ), +] + +# Property [Variables]: $range works with $let and system variables. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"s": "$start", "e": "$end"}, + "in": {"$range": ["$$s", "$$e"]}, + } + }, + doc={"start": 0, "end": 3}, + expected=[0, 1, 2], + msg="$range should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$range": ["$$ROOT.start", "$$ROOT.end"]}, + doc={"_id": 1, "start": 0, "end": 3}, + expected=[0, 1, 2], + msg="$range should work with $$ROOT", + ), + ExpressionTestCase( + "current_variable", + expression={"$range": ["$$CURRENT.start", "$$CURRENT.end"]}, + doc={"_id": 2, "start": 0, "end": 3}, + expected=[0, 1, 2], + msg="$$CURRENT should be equivalent to field path", + ), + ExpressionTestCase( + "remove_variable", + expression={"$range": ["$$REMOVE", 5]}, + doc={"x": 1}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$$REMOVE should error like missing field", + ), +] + +# Property [Null/Missing Fields]: $range errors on null and missing field paths. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_start_field", + expression={"$range": ["$nonexistent", 5]}, + doc={"other": 1}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range missing start field should error", + ), + ExpressionTestCase( + "missing_end_field", + expression={"$range": [0, "$nonexistent"]}, + doc={"other": 1}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range missing end field should error", + ), + ExpressionTestCase( + "null_start_field", + expression={"$range": ["$a", 5]}, + doc={"a": None}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range null start field should error", + ), + ExpressionTestCase( + "null_end_field", + expression={"$range": [0, "$a"]}, + doc={"a": None}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range null end field should error", + ), + ExpressionTestCase( + "null_step_field", + expression={"$range": [0, 5, "$a"]}, + doc={"a": None}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range null step field should error", + ), + ExpressionTestCase( + "all_missing_fields", + expression={"$range": ["$a", "$b"]}, + doc={"_placeholder": 1}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range all missing should error on start first", + ), + ExpressionTestCase( + "composite_array_path_error", + expression={"$range": ["$a.b", 5]}, + doc={"a": [{"b": 0}, {"b": 5}]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range composite array path should error", + ), + ExpressionTestCase( + "array_index_path_error", + expression={"$range": ["$a.0", "$a.1", "$a.2"]}, + doc={"a": [0, 5, 2]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range array index path should error in expression context", + ), +] + +ALL_EXPR_TESTS = FIELD_LOOKUP_TESTS + LET_TESTS + NULL_MISSING_EXPR_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_range_field_paths_and_variables(collection, test): + """Test $range with field paths, $let, system variables, and null/missing errors.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_numeric_acceptance.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_numeric_acceptance.py new file mode 100644 index 000000000..5e4a1b540 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_numeric_acceptance.py @@ -0,0 +1,230 @@ +""" +Numeric type acceptance tests for $range expression. + +Tests Int64, whole doubles, whole Decimal128, single-element results, +negative number ranges, large ranges, and negative zero handling. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + INT64_ZERO, +) + +# Property [Numeric Type Acceptance]: $range accepts Int64, whole doubles, and whole Decimal128. +NUMERIC_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_args", + doc={"start": INT64_ZERO, "end": Int64(3)}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Int64 arguments", + ), + ExpressionTestCase( + "whole_double_args", + doc={"start": DOUBLE_ZERO, "end": 3.0}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept whole-number double arguments", + ), + ExpressionTestCase( + "whole_decimal128_args", + doc={"start": DECIMAL128_ZERO, "end": Decimal128("3")}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept whole-number Decimal128 arguments", + ), + ExpressionTestCase( + "int64_step", + doc={"start": 0, "end": 6, "step": Int64(2)}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range should accept Int64 step", + ), + ExpressionTestCase( + "whole_double_step", + doc={"start": 0, "end": 6, "step": 2.0}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range should accept whole-number double step", + ), + ExpressionTestCase( + "whole_decimal128_step", + doc={"start": 0, "end": 6, "step": Decimal128("2")}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, 2, 4], + msg="$range should accept whole-number Decimal128 step", + ), + ExpressionTestCase( + "decimal128_trailing_zero_start", + doc={"start": Decimal128("0.0"), "end": 3}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Decimal128 0.0 as start", + ), + ExpressionTestCase( + "decimal128_trailing_zero_end", + doc={"start": 0, "end": Decimal128("3.0")}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Decimal128 3.0 as end", + ), + ExpressionTestCase( + "decimal128_trailing_zero_both", + doc={"start": Decimal128("0.0"), "end": Decimal128("3.0")}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2], + msg="$range should accept Decimal128 0.0 start and 3.0 end", + ), +] + +# Property [Single Element]: $range produces single-element arrays. +SINGLE_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "single_element", + doc={"start": 0, "end": 1}, + expression={"$range": ["$start", "$end"]}, + expected=[0], + msg="$range should return single element", + ), + ExpressionTestCase( + "desc_single", + doc={"start": 1, "end": 0, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[1], + msg="$range descending single element", + ), + ExpressionTestCase( + "step_eq_range", + doc={"start": 0, "end": 5, "step": 5}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0], + msg="$range step equal to range should produce single element", + ), + ExpressionTestCase( + "step_gt_range", + doc={"start": 0, "end": 5, "step": 10}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0], + msg="$range step greater than range should produce single element", + ), +] + +# Property [Negative Numbers]: $range handles negative start, end, and crossing zero. +NEGATIVE_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_to_zero", + doc={"start": -5, "end": 0}, + expression={"$range": ["$start", "$end"]}, + expected=[-5, -4, -3, -2, -1], + msg="$range negative start to zero ascending", + ), + ExpressionTestCase( + "zero_to_neg", + doc={"start": 0, "end": -5, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[0, -1, -2, -3, -4], + msg="$range zero to negative descending", + ), + ExpressionTestCase( + "cross_zero_asc", + doc={"start": -3, "end": 3}, + expression={"$range": ["$start", "$end"]}, + expected=[-3, -2, -1, 0, 1, 2], + msg="$range crossing zero ascending", + ), + ExpressionTestCase( + "cross_zero_desc", + doc={"start": 3, "end": -3, "step": -1}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[3, 2, 1, 0, -1, -2], + msg="$range crossing zero descending", + ), + ExpressionTestCase( + "neg_desc_step2", + doc={"start": -10, "end": -20, "step": -2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[-10, -12, -14, -16, -18], + msg="$range negative descending with step -2", + ), + ExpressionTestCase( + "neg_asc_step2", + doc={"start": -10, "end": -1, "step": 2}, + expression={"$range": ["$start", "$end", "$step"]}, + expected=[-10, -8, -6, -4, -2], + msg="$range negative ascending with step 2", + ), +] + +# Property [Large Range]: $range generates large sequences. +LARGE_RANGE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_range", + doc={"start": 0, "end": 1000}, + expression={"$range": ["$start", "$end"]}, + expected=list(range(1000)), + msg="$range should generate large range", + ), +] + +# Property [Negative Zero]: $range treats negative zero as zero. +NEG_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "neg_zero_double_start", + doc={"start": DOUBLE_NEGATIVE_ZERO, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2, 3, 4], + msg="$range negative zero double start treated as 0", + ), + ExpressionTestCase( + "neg_zero_decimal_start", + doc={"start": DECIMAL128_NEGATIVE_ZERO, "end": 5}, + expression={"$range": ["$start", "$end"]}, + expected=[0, 1, 2, 3, 4], + msg="$range negative zero Decimal128 start treated as 0", + ), + ExpressionTestCase( + "neg_zero_double_end", + doc={"start": 0, "end": DOUBLE_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range negative zero double end treated as 0", + ), + ExpressionTestCase( + "neg_zero_decimal_end", + doc={"start": 0, "end": DECIMAL128_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end"]}, + expected=[], + msg="$range negative zero Decimal128 end treated as 0", + ), +] + +ALL_TESTS = ( + NUMERIC_TYPE_TESTS + + SINGLE_ELEMENT_TESTS + + NEGATIVE_RANGE_TESTS + + LARGE_RANGE_TESTS + + NEG_ZERO_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_numeric_acceptance(collection, test): + """Test $range accepts Int64, whole doubles, whole Decimal128, and negative zero.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_type_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_type_errors.py new file mode 100644 index 000000000..34474fda6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_type_errors.py @@ -0,0 +1,292 @@ +""" +Type error tests for $range expression. + +Tests non-numeric types for start, end, and step positions. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + RANGE_END_NOT_NUMERIC_ERROR, + RANGE_START_NOT_INT32_ERROR, + RANGE_STEP_NOT_NUMERIC_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Non-Numeric Start]: $range rejects non-numeric types for start. +NON_NUMERIC_START_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_start", + doc={"start": "hello", "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject string start", + ), + ExpressionTestCase( + "bool_start", + doc={"start": True, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject bool start", + ), + ExpressionTestCase( + "null_start", + doc={"start": None, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject null start", + ), + ExpressionTestCase( + "object_start", + doc={"start": {"a": 1}, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject object start", + ), + ExpressionTestCase( + "array_start", + doc={"start": [1], "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject array start", + ), + ExpressionTestCase( + "objectid_start", + doc={"start": ObjectId(), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject objectid start", + ), + ExpressionTestCase( + "datetime_start", + doc={"start": datetime(2024, 1, 1, tzinfo=timezone.utc), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject datetime start", + ), + ExpressionTestCase( + "binary_start", + doc={"start": Binary(b"x", 0), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject binary start", + ), + ExpressionTestCase( + "regex_start", + doc={"start": Regex("x"), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject regex start", + ), + ExpressionTestCase( + "maxkey_start", + doc={"start": MaxKey(), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject maxkey start", + ), + ExpressionTestCase( + "minkey_start", + doc={"start": MinKey(), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject minkey start", + ), + ExpressionTestCase( + "timestamp_start", + doc={"start": Timestamp(0, 0), "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INT32_ERROR, + msg="$range should reject timestamp start", + ), +] + +# Property [Non-Numeric End]: $range rejects non-numeric types for end. +NON_NUMERIC_END_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_end", + doc={"start": 0, "end": "hello"}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject string end", + ), + ExpressionTestCase( + "bool_end", + doc={"start": 0, "end": True}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject bool end", + ), + ExpressionTestCase( + "null_end", + doc={"start": 0, "end": None}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject null end", + ), + ExpressionTestCase( + "object_end", + doc={"start": 0, "end": {"a": 1}}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject object end", + ), + ExpressionTestCase( + "array_end", + doc={"start": 0, "end": [1]}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject array end", + ), + ExpressionTestCase( + "objectid_end", + doc={"start": 0, "end": ObjectId()}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject objectid end", + ), + ExpressionTestCase( + "datetime_end", + doc={"start": 0, "end": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject datetime end", + ), + ExpressionTestCase( + "binary_end", + doc={"start": 0, "end": Binary(b"x", 0)}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject binary end", + ), + ExpressionTestCase( + "regex_end", + doc={"start": 0, "end": Regex("x")}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject regex end", + ), + ExpressionTestCase( + "maxkey_end", + doc={"start": 0, "end": MaxKey()}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject maxkey end", + ), + ExpressionTestCase( + "minkey_end", + doc={"start": 0, "end": MinKey()}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject minkey end", + ), + ExpressionTestCase( + "timestamp_end", + doc={"start": 0, "end": Timestamp(0, 0)}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_NUMERIC_ERROR, + msg="$range should reject timestamp end", + ), +] + +# Property [Non-Numeric Step]: $range rejects non-numeric types for step. +NON_NUMERIC_STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_step", + doc={"start": 0, "end": 5, "step": "bad"}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject string step", + ), + ExpressionTestCase( + "bool_step", + doc={"start": 0, "end": 5, "step": True}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject bool step", + ), + ExpressionTestCase( + "object_step", + doc={"start": 0, "end": 5, "step": {"a": 1}}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject object step", + ), + ExpressionTestCase( + "array_step", + doc={"start": 0, "end": 5, "step": [1]}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject array step", + ), + ExpressionTestCase( + "objectid_step", + doc={"start": 0, "end": 5, "step": ObjectId()}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject objectid step", + ), + ExpressionTestCase( + "datetime_step", + doc={"start": 0, "end": 5, "step": datetime(2024, 1, 1, tzinfo=timezone.utc)}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject datetime step", + ), + ExpressionTestCase( + "binary_step", + doc={"start": 0, "end": 5, "step": Binary(b"x", 0)}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject binary step", + ), + ExpressionTestCase( + "regex_step", + doc={"start": 0, "end": 5, "step": Regex("x")}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject regex step", + ), + ExpressionTestCase( + "maxkey_step", + doc={"start": 0, "end": 5, "step": MaxKey()}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject maxkey step", + ), + ExpressionTestCase( + "minkey_step", + doc={"start": 0, "end": 5, "step": MinKey()}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject minkey step", + ), + ExpressionTestCase( + "timestamp_step", + doc={"start": 0, "end": 5, "step": Timestamp(0, 0)}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_NUMERIC_ERROR, + msg="$range should reject timestamp step", + ), +] + +ALL_TESTS = NON_NUMERIC_START_TESTS + NON_NUMERIC_END_TESTS + NON_NUMERIC_STEP_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_type_error(collection, test): + """Test $range error with non-numeric types for start, end, step.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_value_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_value_errors.py new file mode 100644 index 000000000..a3e674452 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_range_value_errors.py @@ -0,0 +1,381 @@ +""" +Value error tests for $range expression. + +Tests non-integral values, special numeric values (NaN, Infinity), +step zero, out-of-int32-range values, and wrong arity. +""" + +import pytest +from bson import Decimal128 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + EXPRESSION_ARITY_ERROR, + RANGE_END_NOT_INT32_ERROR, + RANGE_START_NOT_INTEGRAL_ERROR, + RANGE_STEP_NOT_INT32_ERROR, + RANGE_STEP_ZERO_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_HALF, + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ONE_AND_HALF, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_OVERFLOW, + INT32_UNDERFLOW, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Non-Integral Start]: $range rejects fractional numeric values for start. +NON_INTEGRAL_START_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "fractional_start", + doc={"start": 1.5, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject fractional start", + ), + ExpressionTestCase( + "decimal128_fractional_start", + doc={"start": DECIMAL128_HALF, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject fractional Decimal128 start", + ), + ExpressionTestCase( + "negative_fractional_start", + doc={"start": -1.5, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject negative fractional start", + ), + ExpressionTestCase( + "decimal128_negative_fractional_start", + doc={"start": DECIMAL128_NEGATIVE_ONE_AND_HALF, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject negative fractional Decimal128 start", + ), + ExpressionTestCase( + "decimal128_negative_nan_start", + doc={"start": DECIMAL128_NEGATIVE_NAN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Decimal128 -NaN start", + ), +] + +# Property [Non-Integral End]: $range rejects fractional numeric values for end. +NON_INTEGRAL_END_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "fractional_end", + doc={"start": 0, "end": 5.5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject fractional end", + ), + ExpressionTestCase( + "decimal128_fractional_end", + doc={"start": 0, "end": Decimal128("5.5")}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject fractional Decimal128 end", + ), + ExpressionTestCase( + "negative_fractional_end", + doc={"start": 0, "end": -1.5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject negative fractional end", + ), + ExpressionTestCase( + "decimal128_negative_fractional_end", + doc={"start": 0, "end": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject negative fractional Decimal128 end", + ), +] + +# Property [Non-Integral Step]: $range rejects fractional numeric values for step. +NON_INTEGRAL_STEP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "fractional_step", + doc={"start": 0, "end": 10, "step": 1.5}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject fractional step", + ), + ExpressionTestCase( + "decimal128_fractional_step", + doc={"start": 0, "end": 10, "step": DECIMAL128_ONE_AND_HALF}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject fractional Decimal128 step", + ), + ExpressionTestCase( + "negative_fractional_step", + doc={"start": 10, "end": 0, "step": -1.5}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject negative fractional step", + ), + ExpressionTestCase( + "decimal128_negative_fractional_step", + doc={"start": 10, "end": 0, "step": DECIMAL128_NEGATIVE_ONE_AND_HALF}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject negative fractional Decimal128 step", + ), +] + +# Property [Special Numerics]: $range rejects NaN and Infinity values. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_start", + doc={"start": FLOAT_NAN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject NaN start", + ), + ExpressionTestCase( + "inf_start", + doc={"start": FLOAT_INFINITY, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Infinity start", + ), + ExpressionTestCase( + "neg_inf_start", + doc={"start": FLOAT_NEGATIVE_INFINITY, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject -Infinity start", + ), + ExpressionTestCase( + "nan_end", + doc={"start": 0, "end": FLOAT_NAN}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject NaN end", + ), + ExpressionTestCase( + "inf_end", + doc={"start": 0, "end": FLOAT_INFINITY}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject Infinity end", + ), + ExpressionTestCase( + "decimal128_nan_start", + doc={"start": DECIMAL128_NAN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Decimal128 NaN start", + ), + ExpressionTestCase( + "decimal128_inf_start", + doc={"start": DECIMAL128_INFINITY, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject Decimal128 Infinity start", + ), + ExpressionTestCase( + "decimal128_neg_inf_end", + doc={"start": 0, "end": DECIMAL128_NEGATIVE_INFINITY}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject Decimal128 -Infinity end", + ), +] + +# Property [Step Zero]: $range rejects zero step value. +STEP_ZERO_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "step_zero_int", + doc={"start": 0, "end": 5, "step": 0}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject step 0", + ), + ExpressionTestCase( + "step_zero_int64", + doc={"start": 0, "end": 5, "step": INT64_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject Int64 step 0", + ), + ExpressionTestCase( + "step_zero_double", + doc={"start": 0, "end": 5, "step": DOUBLE_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject double step 0.0", + ), + ExpressionTestCase( + "step_zero_decimal128", + doc={"start": 0, "end": 5, "step": DECIMAL128_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject Decimal128 step 0", + ), + ExpressionTestCase( + "step_neg_zero_double", + doc={"start": 0, "end": 5, "step": DOUBLE_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject negative zero double step", + ), + ExpressionTestCase( + "step_neg_zero_decimal128", + doc={"start": 0, "end": 5, "step": DECIMAL128_NEGATIVE_ZERO}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_ZERO_ERROR, + msg="$range should reject negative zero Decimal128 step", + ), +] + +# Property [Out of INT32 Range]: $range rejects values outside int32 range. +OUT_OF_INT32_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "start_int64_max", + doc={"start": INT64_MAX, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT64_MAX start", + ), + ExpressionTestCase( + "start_int64_min", + doc={"start": INT64_MIN, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT64_MIN start", + ), + ExpressionTestCase( + "end_int64_max", + doc={"start": 0, "end": INT64_MAX}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT64_MAX end", + ), + ExpressionTestCase( + "end_int64_min", + doc={"start": 0, "end": INT64_MIN}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT64_MIN end", + ), + ExpressionTestCase( + "step_int64_max", + doc={"start": 0, "end": 5, "step": INT64_MAX}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject INT64_MAX step", + ), + ExpressionTestCase( + "start_int32_overflow", + doc={"start": INT32_OVERFLOW, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT32_OVERFLOW start", + ), + ExpressionTestCase( + "start_int32_underflow", + doc={"start": INT32_UNDERFLOW, "end": 5}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_START_NOT_INTEGRAL_ERROR, + msg="$range should reject INT32_UNDERFLOW start", + ), + ExpressionTestCase( + "end_int32_overflow", + doc={"start": 0, "end": INT32_OVERFLOW}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT32_OVERFLOW end", + ), + ExpressionTestCase( + "end_int32_underflow", + doc={"start": 0, "end": INT32_UNDERFLOW}, + expression={"$range": ["$start", "$end"]}, + error_code=RANGE_END_NOT_INT32_ERROR, + msg="$range should reject INT32_UNDERFLOW end", + ), + ExpressionTestCase( + "step_int32_overflow", + doc={"start": 0, "end": 5, "step": INT32_OVERFLOW}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject INT32_OVERFLOW step", + ), + ExpressionTestCase( + "step_int32_underflow", + doc={"start": 0, "end": 5, "step": INT32_UNDERFLOW}, + expression={"$range": ["$start", "$end", "$step"]}, + error_code=RANGE_STEP_NOT_INT32_ERROR, + msg="$range should reject INT32_UNDERFLOW step", + ), +] + +# Property [Arity]: $range rejects wrong number of arguments. +ARITY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zero_args", + expression={"$range": []}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$range should error with zero arguments", + ), + ExpressionTestCase( + "one_arg", + expression={"$range": [1]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$range should error with one argument", + ), + ExpressionTestCase( + "four_args", + expression={"$range": [1, 2, 3, 4]}, + error_code=EXPRESSION_ARITY_ERROR, + msg="$range should error with four arguments", + ), +] + +ALL_TESTS = ( + NON_INTEGRAL_START_TESTS + + NON_INTEGRAL_END_TESTS + + NON_INTEGRAL_STEP_TESTS + + SPECIAL_NUMERIC_TESTS + + STEP_ZERO_TESTS + + OUT_OF_INT32_TESTS + + ARITY_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_range_value_error(collection, test): + """Test $range error with invalid numeric values.""" + if test.doc is None: + result = execute_expression(collection, test.expression) + else: + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_expression_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_range.py similarity index 89% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_expression_range.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_range.py index a425f73e3..22bf631f0 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_expression_range.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/range/test_smoke_range.py @@ -26,4 +26,4 @@ def test_smoke_expression_range(collection): ) expected = [{"_id": 1, "sequence": [0, 1, 2, 3, 4]}, {"_id": 2, "sequence": [10, 11, 12]}] - assertSuccess(result, expected, msg="Should support $range expression") + assertSuccess(result, expected, "Should support $range expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/__init__.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_expression_zip.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_zip.py similarity index 89% rename from documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_expression_zip.py rename to documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_zip.py index 50f19ac0b..8e8b9d65a 100644 --- a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_expression_zip.py +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_smoke_zip.py @@ -28,4 +28,4 @@ def test_smoke_expression_zip(collection): ) expected = [{"_id": 1, "zipped": [[1, 10], [2, 20]]}, {"_id": 2, "zipped": [[3, 30], [4, 40]]}] - assertSuccess(result, expected, msg="Should support $zip expression") + assertSuccess(result, expected, "Should support $zip expression", ignore_doc_order=True) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_argument_structure_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_argument_structure_errors.py new file mode 100644 index 000000000..335eca07b --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_argument_structure_errors.py @@ -0,0 +1,134 @@ +""" +Argument handling tests for $zip expression. + +Tests object structure validation: missing fields, extra fields, +non-object argument, empty inputs, inputs not being an array, +useLongestLength truthy/falsy coercion, and defaults type variety. +""" + +import pytest +from bson import MaxKey, MinKey + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + ZIP_INPUTS_NOT_ARRAY_ERROR, + ZIP_MISSING_INPUTS_ERROR, + ZIP_UNKNOWN_FIELD_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Argument Structure]: $zip rejects malformed arguments. +STRUCTURE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "empty_object", + expression={"$zip": {}}, + error_code=ZIP_MISSING_INPUTS_ERROR, + msg="$zip empty object should error", + ), + ExpressionTestCase( + "missing_inputs", + expression={"$zip": {"useLongestLength": True}}, + error_code=ZIP_MISSING_INPUTS_ERROR, + msg="$zip missing inputs should error", + ), + ExpressionTestCase( + "unknown_field", + expression={"$zip": {"inputs": [[1], [2]], "extra": 1}}, + error_code=ZIP_UNKNOWN_FIELD_ERROR, + msg="$zip unknown field should error", + ), + ExpressionTestCase( + "inputs_not_array", + expression={"$zip": {"inputs": "bad"}}, + error_code=ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + msg="$zip non-array inputs should error", + ), + ExpressionTestCase( + "inputs_not_array_int", + expression={"$zip": {"inputs": 1}}, + error_code=ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + msg="$zip int inputs should error", + ), + ExpressionTestCase( + "inputs_empty_array", + expression={"$zip": {"inputs": []}}, + error_code=ZIP_MISSING_INPUTS_ERROR, + msg="$zip empty inputs array should error", + ), + ExpressionTestCase( + "inputs_as_object", + expression={"$zip": {"inputs": {"a": "b"}}}, + error_code=ZIP_INPUT_ELEMENT_NOT_ARRAY_ERROR, + msg="$zip inputs as object should error", + ), +] + +# Property [Object Argument]: $zip rejects non-object arguments. +NON_OBJECT_ARG_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int_arg", + expression={"$zip": 1}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip int argument should error", + ), + ExpressionTestCase( + "string_arg", + expression={"$zip": "abc"}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip string argument should error", + ), + ExpressionTestCase( + "array_arg", + expression={"$zip": [1, 2]}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip array argument should error", + ), + ExpressionTestCase( + "null_arg", + expression={"$zip": None}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip null argument should error", + ), + ExpressionTestCase( + "bool_arg", + expression={"$zip": True}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip bool argument should error", + ), + ExpressionTestCase( + "double_arg", + expression={"$zip": 1.5}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip double argument should error", + ), + ExpressionTestCase( + "minkey_arg", + expression={"$zip": MinKey()}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip minKey argument should error", + ), + ExpressionTestCase( + "maxkey_arg", + expression={"$zip": MaxKey()}, + error_code=ZIP_INPUTS_NOT_ARRAY_ERROR, + msg="$zip maxKey argument should error", + ), +] + +ALL_STRUCTURE_TESTS = STRUCTURE_ERROR_TESTS + NON_OBJECT_ARG_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_STRUCTURE_TESTS)) +def test_zip_argument_handling(collection, test): + """Test $zip argument structure validation.""" + result = execute_expression(collection, test.expression) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_bson_types.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_bson_types.py new file mode 100644 index 000000000..ff502fff7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_bson_types.py @@ -0,0 +1,395 @@ +""" +BSON type element preservation tests for $zip expression. + +Tests that various BSON types are preserved when zipping arrays. +""" + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from bson import Binary, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_ONE_AND_HALF, + DECIMAL128_TWO_AND_HALF, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, + INT64_ZERO, +) + +# Property [Type Preservation]: $zip preserves each element's BSON type. +BSON_TYPE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int64_values", + doc={"arr0": [Int64(1)], "arr1": [Int64(2)]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[Int64(1), Int64(2)]], + msg="$zip should preserve Int64 values", + ), + ExpressionTestCase( + "decimal128_values", + doc={"arr0": [DECIMAL128_ONE_AND_HALF], "arr1": [DECIMAL128_TWO_AND_HALF]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DECIMAL128_ONE_AND_HALF, DECIMAL128_TWO_AND_HALF]], + msg="$zip should preserve Decimal128 values", + ), + ExpressionTestCase( + "datetime_values", + doc={ + "arr0": [datetime(2024, 1, 1, tzinfo=timezone.utc)], + "arr1": [datetime(2024, 6, 1, tzinfo=timezone.utc)], + }, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[ + [datetime(2024, 1, 1, tzinfo=timezone.utc), datetime(2024, 6, 1, tzinfo=timezone.utc)] + ], + msg="$zip should preserve datetime values", + ), + ExpressionTestCase( + "objectid_values", + doc={ + "arr0": [ObjectId("000000000000000000000001")], + "arr1": [ObjectId("000000000000000000000002")], + }, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[ObjectId("000000000000000000000001"), ObjectId("000000000000000000000002")]], + msg="$zip should preserve ObjectId values", + ), + ExpressionTestCase( + "binary_values", + doc={"arr0": [Binary(b"\x01", 0)], "arr1": [Binary(b"\x02", 0)]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[b"\x01", b"\x02"]], + msg="$zip should preserve Binary values", + ), + ExpressionTestCase( + "regex_values", + doc={"arr0": [Regex("^a", "i")], "arr1": [Regex("^b", "i")]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[Regex("^a", "i"), Regex("^b", "i")]], + msg="$zip should preserve Regex values", + ), + ExpressionTestCase( + "timestamp_values", + doc={"arr0": [Timestamp(1, 0)], "arr1": [Timestamp(2, 0)]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[Timestamp(1, 0), Timestamp(2, 0)]], + msg="$zip should preserve Timestamp values", + ), + ExpressionTestCase( + "minkey_maxkey", + doc={"arr0": [MinKey()], "arr1": [MaxKey()]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[MinKey(), MaxKey()]], + msg="$zip should preserve MinKey/MaxKey values", + ), + ExpressionTestCase( + "uuid_values", + doc={ + "arr0": [Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210"))], + "arr1": [Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef"))], + }, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[ + [ + Binary.from_uuid(UUID("01234567-89ab-cdef-fedc-ba9876543210")), + Binary.from_uuid(UUID("fedcba98-7654-3210-0123-456789abcdef")), + ] + ], + msg="$zip should preserve UUID binary values", + ), +] + +# Property [Mixed Types]: $zip processes arrays with mixed BSON types. +MIXED_BSON_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "mixed_bson_types", + doc={"arr0": [1, "two", Int64(3)], "arr1": [Decimal128("4"), True, MinKey()]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, Decimal128("4")], ["two", True], [Int64(3), MinKey()]], + msg="$zip should zip mixed BSON types preserving each", + ), +] + +# Property [Special Numerics]: $zip preserves NaN, Infinity, and boundary values. +SPECIAL_NUMERIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "infinity_values", + doc={"arr0": [FLOAT_INFINITY], "arr1": [FLOAT_NEGATIVE_INFINITY]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY]], + msg="$zip should preserve infinity values", + ), + ExpressionTestCase( + "decimal128_infinity", + doc={"arr0": [DECIMAL128_INFINITY], "arr1": [DECIMAL128_NEGATIVE_INFINITY]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY]], + msg="$zip should preserve Decimal128 infinity values", + ), + ExpressionTestCase( + "boundary_values", + doc={"arr0": [INT32_MIN, INT32_MAX], "arr1": [INT64_MIN, INT64_MAX]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[INT32_MIN, INT64_MIN], [INT32_MAX, INT64_MAX]], + msg="$zip should preserve numeric boundary values", + ), + ExpressionTestCase( + "negative_zero", + doc={"arr0": [DOUBLE_NEGATIVE_ZERO], "arr1": [0]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DOUBLE_NEGATIVE_ZERO, 0]], + msg="$zip should preserve negative zero", + ), + ExpressionTestCase( + "decimal128_nan", + doc={"arr0": [DECIMAL128_NAN], "arr1": [Decimal128("1")]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[DECIMAL128_NAN, Decimal128("1")]], + msg="$zip should preserve Decimal128 NaN", + ), +] + +# Property [BSON Defaults]: $zip uses BSON-typed default values correctly. +BSON_DEFAULTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "default_int64", + doc={"arr0": [1, 2, 3], "arr1": [Int64(10)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, INT64_ZERO], + } + }, + expected=[[1, Int64(10)], [2, INT64_ZERO], [3, INT64_ZERO]], + msg="$zip should use Int64 default value", + ), + ExpressionTestCase( + "default_decimal128", + doc={"arr0": [1, 2, 3], "arr1": [DECIMAL128_ONE_AND_HALF]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DECIMAL128_ZERO], + } + }, + expected=[[1, DECIMAL128_ONE_AND_HALF], [2, DECIMAL128_ZERO], [3, DECIMAL128_ZERO]], + msg="$zip should use Decimal128 default value", + ), + ExpressionTestCase( + "default_datetime", + doc={"arr0": [1, 2], "arr1": [datetime(2024, 1, 1, tzinfo=timezone.utc)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, datetime(1970, 1, 1, tzinfo=timezone.utc)], + } + }, + expected=[ + [1, datetime(2024, 1, 1, tzinfo=timezone.utc)], + [2, datetime(1970, 1, 1, tzinfo=timezone.utc)], + ], + msg="$zip should use datetime default value", + ), + ExpressionTestCase( + "default_objectid", + doc={"arr0": [1, 2], "arr1": [ObjectId("000000000000000000000001")]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, ObjectId("000000000000000000000000")], + } + }, + expected=[ + [1, ObjectId("000000000000000000000001")], + [2, ObjectId("000000000000000000000000")], + ], + msg="$zip should use ObjectId default value", + ), + ExpressionTestCase( + "default_timestamp", + doc={"arr0": [1, 2], "arr1": [Timestamp(1, 0)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, Timestamp(0, 0)], + } + }, + expected=[[1, Timestamp(1, 0)], [2, Timestamp(0, 0)]], + msg="$zip should use Timestamp default value", + ), + ExpressionTestCase( + "default_regex", + doc={"arr0": [1, 2], "arr1": [Regex("^a", "i")]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, Regex(".*", "")], + } + }, + expected=[[1, Regex("^a", "i")], [2, Regex(".*", "")]], + msg="$zip should use Regex default value", + ), + ExpressionTestCase( + "default_minkey_maxkey", + doc={"arr0": [1, 2], "arr1": [MinKey()]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, MaxKey()], + } + }, + expected=[[1, MinKey()], [2, MaxKey()]], + msg="$zip should use MaxKey default value", + ), + ExpressionTestCase( + "default_binary", + doc={"arr0": [1, 2], "arr1": [Binary(b"\x01", 0)]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, Binary(b"\x00", 0)], + } + }, + expected=[[1, b"\x01"], [2, b"\x00"]], + msg="$zip should use Binary default value", + ), +] + +# Property [Special Numeric Defaults]: $zip uses special numeric values as defaults. +SPECIAL_NUMERIC_DEFAULTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "default_infinity", + doc={"arr0": [1, 2], "arr1": [FLOAT_INFINITY]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, FLOAT_INFINITY], + } + }, + expected=[[1, FLOAT_INFINITY], [2, FLOAT_INFINITY]], + msg="$zip should use infinity as default", + ), + ExpressionTestCase( + "default_negative_infinity", + doc={"arr0": [1, 2], "arr1": [FLOAT_NEGATIVE_INFINITY]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, FLOAT_NEGATIVE_INFINITY], + } + }, + expected=[[1, FLOAT_NEGATIVE_INFINITY], [2, FLOAT_NEGATIVE_INFINITY]], + msg="$zip should use negative infinity as default", + ), + ExpressionTestCase( + "default_negative_zero", + doc={"arr0": [1, 2], "arr1": [DOUBLE_NEGATIVE_ZERO]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DOUBLE_NEGATIVE_ZERO], + } + }, + expected=[[1, DOUBLE_NEGATIVE_ZERO], [2, DOUBLE_NEGATIVE_ZERO]], + msg="$zip should use negative zero as default", + ), + ExpressionTestCase( + "default_int32_boundaries", + doc={"arr0": [1, 2], "arr1": [INT32_MIN]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, INT32_MAX], + } + }, + expected=[[1, INT32_MIN], [2, INT32_MAX]], + msg="$zip should use INT32_MAX as default", + ), + ExpressionTestCase( + "default_int64_boundaries", + doc={"arr0": [1, 2], "arr1": [INT64_MIN]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, INT64_MAX], + } + }, + expected=[[1, INT64_MIN], [2, INT64_MAX]], + msg="$zip should use INT64_MAX as default", + ), + ExpressionTestCase( + "default_decimal128_infinity", + doc={"arr0": [1, 2], "arr1": [DECIMAL128_INFINITY]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DECIMAL128_NEGATIVE_INFINITY], + } + }, + expected=[[1, DECIMAL128_INFINITY], [2, DECIMAL128_NEGATIVE_INFINITY]], + msg="$zip should use Decimal128 negative infinity as default", + ), + ExpressionTestCase( + "default_decimal128_nan", + doc={"arr0": [1, 2], "arr1": [DECIMAL128_NAN]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, DECIMAL128_NAN], + } + }, + expected=[[1, DECIMAL128_NAN], [2, DECIMAL128_NAN]], + msg="$zip should use Decimal128 NaN as default", + ), +] + +ALL_BSON_TESTS = ( + BSON_TYPE_TESTS + + MIXED_BSON_TESTS + + SPECIAL_NUMERIC_TESTS + + BSON_DEFAULTS_TESTS + + SPECIAL_NUMERIC_DEFAULTS_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_BSON_TESTS)) +def test_zip_bson_type_preservation(collection, test): + """Test $zip preserves BSON types in zipped output arrays.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_core_behavior.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_core_behavior.py new file mode 100644 index 000000000..33468aaa7 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_core_behavior.py @@ -0,0 +1,230 @@ +""" +Core behavior tests for $zip expression. + +Tests zipping arrays of various element types, equal/unequal lengths, +useLongestLength, and defaults. +""" + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Basic Transform]: $zip transposes arrays element-wise. +BASIC_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "two_int_arrays", + doc={"arr0": [1, 2, 3], "arr1": [10, 20, 30]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], [2, 20], [3, 30]], + msg="$zip should zip two int arrays", + ), + ExpressionTestCase( + "two_string_arrays", + doc={"arr0": ["a", "b"], "arr1": ["c", "d"]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[["a", "c"], ["b", "d"]], + msg="$zip should zip two string arrays", + ), + ExpressionTestCase( + "three_arrays", + doc={"arr0": [1, 2], "arr1": [10, 20], "arr2": [100, 200]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[[1, 10, 100], [2, 20, 200]], + msg="$zip should zip three arrays", + ), + ExpressionTestCase( + "mixed_type_elements", + doc={"arr0": [1, "two"], "arr1": [True, None]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, True], ["two", None]], + msg="$zip should zip arrays with mixed types", + ), + ExpressionTestCase( + "numeric_cross_types", + doc={"arr0": [1, Int64(2)], "arr1": [3.0, Decimal128("4")]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 3.0], [Int64(2), Decimal128("4")]], + msg="$zip should zip mixed numeric type arrays", + ), +] + +# Property [Unequal Length]: $zip truncates to the shortest array. +UNEQUAL_LENGTH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "first_shorter", + doc={"arr0": [1, 2], "arr1": [10, 20, 30]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], [2, 20]], + msg="$zip should truncate to shorter first array", + ), + ExpressionTestCase( + "second_shorter", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], [2, 20]], + msg="$zip should truncate to shorter second array", + ), + ExpressionTestCase( + "one_empty", + doc={"arr0": [], "arr1": [1, 2, 3]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[], + msg="$zip empty array should produce empty result", + ), +] + +# Property [Longest Length]: $zip pads to longest array when useLongestLength is true. +USE_LONGEST_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "longest_pads_null", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[[1, 10], [2, 20], [3, None]], + msg="$zip should pad shorter array with null", + ), + ExpressionTestCase( + "longest_both_short", + doc={"arr0": [1], "arr1": [10, 20], "arr2": [100, 200, 300]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"], "useLongestLength": True}}, + expected=[[1, 10, 100], [None, 20, 200], [None, None, 300]], + msg="$zip should pad multiple shorter arrays with null", + ), + ExpressionTestCase( + "longest_equal_length", + doc={"arr0": [1, 2], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[[1, 10], [2, 20]], + msg="$zip equal length with useLongestLength should behave same", + ), + ExpressionTestCase( + "longest_false_truncates", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": False}}, + expected=[[1, 10], [2, 20]], + msg="$zip useLongestLength false should truncate", + ), +] + +# Property [Defaults]: $zip pads shorter arrays with default values. +DEFAULTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "defaults_fill_shorter", + doc={"arr0": [1, 2, 3], "arr1": [10, 20]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, 0]} + }, + expected=[[1, 10], [2, 20], [3, 0]], + msg="$zip should fill shorter array with default value", + ), + ExpressionTestCase( + "defaults_multiple_arrays", + doc={"arr0": [1], "arr1": [10, 20], "arr2": [100, 200, 300]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1", "$arr2"], + "useLongestLength": True, + "defaults": [-1, -2, -3], + } + }, + expected=[[1, 10, 100], [-1, 20, 200], [-1, -2, 300]], + msg="$zip should fill multiple shorter arrays with respective defaults", + ), + ExpressionTestCase( + "defaults_null_value", + doc={"arr0": [1, 2, 3], "arr1": [10]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [None, None], + } + }, + expected=[[1, 10], [2, None], [3, None]], + msg="$zip null defaults should work same as no defaults", + ), + ExpressionTestCase( + "defaults_string", + doc={"arr0": [1, 2, 3], "arr1": ["a"]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [0, "missing"], + } + }, + expected=[[1, "a"], [2, "missing"], [3, "missing"]], + msg="$zip should use string default value", + ), + ExpressionTestCase( + "defaults_not_used_equal_length", + doc={"arr0": [1, 2], "arr1": [10, 20]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, 0]} + }, + expected=[[1, 10], [2, 20]], + msg="$zip defaults not used when arrays are equal length", + ), + ExpressionTestCase( + "defaults_falsy_empty_string", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, ""]} + }, + expected=[[1, "a"], [2, ""]], + msg="$zip falsy defaults (empty string) used correctly", + ), + ExpressionTestCase( + "defaults_false", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, False]} + }, + expected=[[1, "a"], [2, False]], + msg="$zip false default used correctly", + ), + ExpressionTestCase( + "defaults_complex_types", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [{"x": 1}, [1, 2]], + } + }, + expected=[[1, "a"], [2, [1, 2]]], + msg="$zip complex type defaults used correctly", + ), + ExpressionTestCase( + "defaults_nested_array", + doc={"arr0": [1, 2], "arr1": ["a"]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1"], + "useLongestLength": True, + "defaults": [[1, 2], "fallback"], + } + }, + expected=[[1, "a"], [2, "fallback"]], + msg="$zip nested array default used as-is", + ), +] + +ALL_TESTS = BASIC_TESTS + UNEQUAL_LENGTH_TESTS + USE_LONGEST_TESTS + DEFAULTS_TESTS + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_zip_core_behavior(collection, test): + """Test $zip core behavior: basic zipping, unequal lengths, useLongestLength, defaults.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_degenerate_cases.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_degenerate_cases.py new file mode 100644 index 000000000..09e8faf6a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_degenerate_cases.py @@ -0,0 +1,239 @@ +""" +Degenerate and edge case tests for $zip expression. + +Tests empty arrays, single arrays, nested arrays, null propagation, +objects as elements, large arrays, many inputs, and multi-length arrays. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Empty/Single]: $zip handles empty arrays and single-element arrays. +DEGENERATE_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "both_empty", + doc={"arr0": [], "arr1": []}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[], + msg="$zip should return empty for two empty arrays", + ), + ExpressionTestCase( + "three_empty", + doc={"arr0": [], "arr1": [], "arr2": []}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[], + msg="$zip three empty arrays return []", + ), + ExpressionTestCase( + "single_empty", + doc={"arr0": []}, + expression={"$zip": {"inputs": ["$arr0"]}}, + expected=[], + msg="$zip single empty array returns []", + ), + ExpressionTestCase( + "single_element_each", + doc={"arr0": [1], "arr1": [10]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10]], + msg="$zip should zip single-element arrays", + ), + ExpressionTestCase( + "single_input_array", + doc={"arr0": [1, 2, 3]}, + expression={"$zip": {"inputs": ["$arr0"]}}, + expected=[[1], [2], [3]], + msg="$zip single input should wrap each element", + ), + ExpressionTestCase( + "all_single_element_three", + doc={"arr0": [1], "arr1": ["a"], "arr2": [True]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[[1, "a", True]], + msg="$zip all single-element arrays produce one row", + ), + ExpressionTestCase( + "empty_with_longest_true", + doc={"arr0": [], "arr1": [1, 2, 3]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[[None, 1], [None, 2], [None, 3]], + msg="$zip empty array with longest length pads with null", + ), + ExpressionTestCase( + "two_empty_longest_true", + doc={"arr0": [], "arr1": []}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True}}, + expected=[], + msg="$zip two empty arrays with longest length return []", + ), +] + +# Property [Nested Arrays]: $zip preserves nested array and object elements. +NESTED_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_arrays", + doc={"arr0": [[1, 2], [3, 4]], "arr1": ["a", "b"]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[[1, 2], "a"], [[3, 4], "b"]], + msg="$zip should zip nested arrays as elements", + ), + ExpressionTestCase( + "objects_as_elements", + doc={"arr0": [{"x": 1}, {"x": 2}], "arr1": [{"y": 10}, {"y": 20}]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[{"x": 1}, {"y": 10}], [{"x": 2}, {"y": 20}]], + msg="$zip objects preserved as elements", + ), + ExpressionTestCase( + "mixed_types_six", + doc={"arr0": [1, "a", None, True, {"k": 1}, [9]], "arr1": [10, 20, 30, 40, 50, 60]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, 10], ["a", 20], [None, 30], [True, 40], [{"k": 1}, 50], [[9], 60]], + msg="$zip mixed types preserved in transposition", + ), +] + +# Property [Null Propagation]: $zip returns null when any input array is null. +NULL_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "null_first_input", + doc={"arr0": None, "arr1": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=None, + msg="$zip should return null when first input is null", + ), + ExpressionTestCase( + "null_second_input", + doc={"arr0": [1, 2], "arr1": None}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=None, + msg="$zip should return null when second input is null", + ), + ExpressionTestCase( + "all_null_inputs", + doc={"arr0": None, "arr1": None}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=None, + msg="$zip should return null when all inputs are null", + ), + ExpressionTestCase( + "null_elements_in_arrays", + doc={"arr0": [1, None], "arr1": [None, 2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[1, None], [None, 2]], + msg="$zip should preserve null elements within arrays", + ), +] + +# Property [Object Elements]: $zip preserves object elements within arrays. +OBJECT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "arrays_of_objects", + doc={"arr0": [{"a": 1}], "arr1": [{"b": 2}]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[{"a": 1}, {"b": 2}]], + msg="$zip should zip arrays of objects", + ), +] + +# Property [Large Arrays]: $zip handles large arrays. +LARGE_ARRAY_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "large_arrays", + doc={"arr0": list(range(1000)), "arr1": list(range(1000, 2000))}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + expected=[[i, i + 1000] for i in range(1000)], + msg="$zip should zip large arrays", + ), +] + +# Property [Many Inputs]: $zip handles many input arrays. +MANY_INPUTS_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "ten_inputs", + doc={f"arr{i}": [i] for i in range(10)}, + expression={"$zip": {"inputs": [f"$arr{i}" for i in range(10)]}}, + expected=[list(range(10))], + msg="$zip ten inputs transpose correctly", + ), + ExpressionTestCase( + "fifty_inputs", + doc={f"arr{i}": [i] for i in range(50)}, + expression={"$zip": {"inputs": [f"$arr{i}" for i in range(50)]}}, + expected=[list(range(50))], + msg="$zip 50 single-element inputs produce one 50-element row", + ), +] + +# Property [Multi-Length]: $zip handles multiple arrays of varying lengths. +MULTI_LENGTH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "three_arrays_shortest", + doc={"arr0": [1], "arr1": [10, 20, 30], "arr2": [100, 200, 300, 400, 500]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + expected=[[1, 10, 100]], + msg="$zip three arrays shortest = 1", + ), + ExpressionTestCase( + "three_arrays_longest_no_defaults", + doc={"arr0": [1], "arr1": [10, 20, 30], "arr2": [100, 200, 300, 400, 500]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"], "useLongestLength": True}}, + expected=[ + [1, 10, 100], + [None, 20, 200], + [None, 30, 300], + [None, None, 400], + [None, None, 500], + ], + msg="$zip three arrays longest pads with null", + ), + ExpressionTestCase( + "three_arrays_longest_with_defaults", + doc={"arr0": [1], "arr1": [10, 20, 30], "arr2": [100, 200, 300, 400, 500]}, + expression={ + "$zip": { + "inputs": ["$arr0", "$arr1", "$arr2"], + "useLongestLength": True, + "defaults": [0, "x", False], + } + }, + expected=[[1, 10, 100], [0, 20, 200], [0, 30, 300], [0, "x", 400], [0, "x", 500]], + msg="$zip three arrays longest with defaults", + ), + ExpressionTestCase( + "four_arrays_two_empty", + doc={"arr0": [], "arr1": [], "arr2": [1, 2], "arr3": [3, 4, 5]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1", "$arr2", "$arr3"], "useLongestLength": True} + }, + expected=[[None, None, 1, 3], [None, None, 2, 4], [None, None, None, 5]], + msg="$zip four arrays two empty with longest", + ), +] + +ALL_TESTS = ( + DEGENERATE_TESTS + + NESTED_ARRAY_TESTS + + NULL_TESTS + + OBJECT_TESTS + + LARGE_ARRAY_TESTS + + MANY_INPUTS_TESTS + + MULTI_LENGTH_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_TESTS)) +def test_zip_edge_cases(collection, test): + """Test $zip with empty, single, nested, null, large, and multi-length inputs.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_errors.py new file mode 100644 index 000000000..5aa2a79b6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_errors.py @@ -0,0 +1,443 @@ +""" +Error tests for $zip expression. + +Tests non-array inputs, invalid useLongestLength, invalid defaults, +defaults without useLongestLength, and defaults length mismatch. +""" + +from datetime import datetime, timezone + +import pytest +from bson import Binary, Code, Decimal128, Int64, MaxKey, MinKey, ObjectId, Regex, Timestamp + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ( + ZIP_DEFAULTS_LENGTH_MISMATCH_ERROR, + ZIP_DEFAULTS_NOT_ARRAY_ERROR, + ZIP_DEFAULTS_WITHOUT_LONGEST_ERROR, + ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + ZIP_USE_LONGEST_NOT_BOOL_ERROR, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_MAX, + DECIMAL128_MIN, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_ZERO, + DOUBLE_NEGATIVE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, + INT32_MAX, + INT32_MIN, + INT64_MAX, + INT64_MIN, +) + +# Property [Non-Array Element]: $zip rejects non-array elements in inputs with all BSON types. +NOT_ARRAY_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "string_input", + doc={"arr0": "hello", "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject string input element", + ), + ExpressionTestCase( + "int_input", + doc={"arr0": 42, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject int input element", + ), + ExpressionTestCase( + "negative_int_input", + doc={"arr0": -42, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject negative int input element", + ), + ExpressionTestCase( + "bool_input", + doc={"arr0": True, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject bool input element", + ), + ExpressionTestCase( + "object_input", + doc={"arr0": {"a": 1}, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject object input element", + ), + ExpressionTestCase( + "double_input", + doc={"arr0": 3.14, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject double input element", + ), + ExpressionTestCase( + "negative_double_input", + doc={"arr0": -3.14, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject negative double input element", + ), + ExpressionTestCase( + "decimal128_input", + doc={"arr0": Decimal128("1"), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject decimal128 input element", + ), + ExpressionTestCase( + "int64_input", + doc={"arr0": Int64(1), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject int64 input element", + ), + ExpressionTestCase( + "objectid_input", + doc={"arr0": ObjectId(), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject objectid input element", + ), + ExpressionTestCase( + "datetime_input", + doc={"arr0": datetime(2024, 1, 1, tzinfo=timezone.utc), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject datetime input element", + ), + ExpressionTestCase( + "binary_input", + doc={"arr0": Binary(b"x", 0), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject binary input element", + ), + ExpressionTestCase( + "regex_input", + doc={"arr0": Regex("x"), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject regex input element", + ), + ExpressionTestCase( + "code_input", + doc={"arr0": Code("function(){}"), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject javascript code input element", + ), + ExpressionTestCase( + "maxkey_input", + doc={"arr0": MaxKey(), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject maxkey input element", + ), + ExpressionTestCase( + "minkey_input", + doc={"arr0": MinKey(), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject minkey input element", + ), + ExpressionTestCase( + "timestamp_input", + doc={"arr0": Timestamp(0, 0), "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject timestamp input element", + ), + ExpressionTestCase( + "non_array_second_position", + doc={"arr0": [1], "arr1": 42}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject non-array in second position", + ), + ExpressionTestCase( + "non_array_middle_position", + doc={"arr0": [1], "arr1": "bad", "arr2": [2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1", "$arr2"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject non-array in middle position", + ), +] + +# Property [Special Numeric Input]: $zip rejects special numeric values as input. +SPECIAL_NUMERIC_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nan_input", + doc={"arr0": FLOAT_NAN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject NaN input element", + ), + ExpressionTestCase( + "inf_input", + doc={"arr0": FLOAT_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Infinity input element", + ), + ExpressionTestCase( + "neg_inf_input", + doc={"arr0": FLOAT_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject -Infinity input element", + ), + ExpressionTestCase( + "neg_zero_input", + doc={"arr0": DOUBLE_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject negative zero input element", + ), + ExpressionTestCase( + "decimal128_nan_input", + doc={"arr0": DECIMAL128_NAN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 NaN input element", + ), + ExpressionTestCase( + "decimal128_inf_input", + doc={"arr0": DECIMAL128_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 Infinity input element", + ), + ExpressionTestCase( + "decimal128_neg_inf_input", + doc={"arr0": DECIMAL128_NEGATIVE_INFINITY, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 -Infinity input element", + ), + ExpressionTestCase( + "decimal128_neg_zero_input", + doc={"arr0": DECIMAL128_NEGATIVE_ZERO, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject Decimal128 -0 input element", + ), +] + +# Property [Boundary Input]: $zip rejects numeric boundary values as input. +BOUNDARY_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "int32_max_input", + doc={"arr0": INT32_MAX, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT32_MAX input element", + ), + ExpressionTestCase( + "int32_min_input", + doc={"arr0": INT32_MIN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT32_MIN input element", + ), + ExpressionTestCase( + "int64_max_input", + doc={"arr0": INT64_MAX, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT64_MAX input element", + ), + ExpressionTestCase( + "int64_min_input", + doc={"arr0": INT64_MIN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject INT64_MIN input element", + ), + ExpressionTestCase( + "decimal128_max_input", + doc={"arr0": DECIMAL128_MAX, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject DECIMAL128_MAX input element", + ), + ExpressionTestCase( + "decimal128_min_input", + doc={"arr0": DECIMAL128_MIN, "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject DECIMAL128_MIN input element", + ), +] + +# Property [String Element]: $zip rejects string values as input elements. +STRING_EDGE_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "comma_separated_string_input", + doc={"arr0": "1, 2, 3", "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject comma-separated string", + ), + ExpressionTestCase( + "json_like_string_input", + doc={"arr0": "[1, 2, 3]", "arr1": [1]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"]}}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip should reject JSON-like string", + ), +] + +# Property [Invalid useLongestLength]: $zip rejects invalid useLongestLength values. +USE_LONGEST_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "use_longest_string", + doc={"arr0": [1], "arr1": [2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": "true"}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip should reject string useLongestLength", + ), + ExpressionTestCase( + "use_longest_int", + doc={"arr0": [1], "arr1": [2]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": 1}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip should reject int useLongestLength", + ), + ExpressionTestCase( + "use_longest_int_0", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": 0}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip int 0 should error (not bool)", + ), + ExpressionTestCase( + "use_longest_empty_string", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": ""}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip empty string should error (not bool)", + ), + ExpressionTestCase( + "use_longest_array", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": []}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip empty array should error (not bool)", + ), + ExpressionTestCase( + "use_longest_object", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": {"a": 1}}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip object should error (not bool)", + ), + ExpressionTestCase( + "use_longest_nan", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": FLOAT_NAN}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip naN should error (not bool)", + ), + ExpressionTestCase( + "use_longest_infinity", + doc={"arr0": [1, 2]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": FLOAT_INFINITY}}, + error_code=ZIP_USE_LONGEST_NOT_BOOL_ERROR, + msg="$zip infinity should error (not bool)", + ), +] + +# Property [Invalid Defaults]: $zip rejects invalid defaults values. +DEFAULTS_ERROR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "defaults_without_use_longest", + doc={"arr0": [1, 2], "arr1": [3]}, + expression={"$zip": {"inputs": ["$arr0", "$arr1"], "defaults": [0, 0]}}, + error_code=ZIP_DEFAULTS_WITHOUT_LONGEST_ERROR, + msg="$zip should reject defaults without useLongestLength", + ), + ExpressionTestCase( + "defaults_without_longest_false", + doc={"arr0": [1], "arr1": [2]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": False, "defaults": [0, 0]} + }, + error_code=ZIP_DEFAULTS_WITHOUT_LONGEST_ERROR, + msg="$zip defaults with useLongestLength false should error", + ), + ExpressionTestCase( + "defaults_length_mismatch", + doc={"arr0": [1, 2], "arr1": [3]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0]} + }, + error_code=ZIP_DEFAULTS_LENGTH_MISMATCH_ERROR, + msg="$zip should reject defaults with wrong length", + ), + ExpressionTestCase( + "defaults_too_many", + doc={"arr0": [1], "arr1": [2]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": [0, 0, 0]} + }, + error_code=ZIP_DEFAULTS_LENGTH_MISMATCH_ERROR, + msg="$zip should reject defaults longer than inputs", + ), + ExpressionTestCase( + "defaults_not_array", + doc={"arr0": [1], "arr1": [2]}, + expression={ + "$zip": {"inputs": ["$arr0", "$arr1"], "useLongestLength": True, "defaults": "bad"} + }, + error_code=ZIP_DEFAULTS_NOT_ARRAY_ERROR, + msg="$zip should reject non-array defaults", + ), + ExpressionTestCase( + "defaults_not_array_object", + doc={"arr0": [1]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": True, "defaults": {"a": 1}}}, + error_code=ZIP_DEFAULTS_NOT_ARRAY_ERROR, + msg="$zip defaults as object should error", + ), + ExpressionTestCase( + "defaults_not_array_int", + doc={"arr0": [1]}, + expression={"$zip": {"inputs": ["$arr0"], "useLongestLength": True, "defaults": 1}}, + error_code=ZIP_DEFAULTS_NOT_ARRAY_ERROR, + msg="$zip defaults as int should error", + ), +] + +ALL_INPUT_ELEMENT_TESTS = ( + NOT_ARRAY_ELEMENT_TESTS + + SPECIAL_NUMERIC_ERROR_TESTS + + BOUNDARY_ERROR_TESTS + + STRING_EDGE_ERROR_TESTS + + USE_LONGEST_ERROR_TESTS + + DEFAULTS_ERROR_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_INPUT_ELEMENT_TESTS)) +def test_zip_non_array_input_error(collection, test): + """Test $zip rejects non-array inputs and invalid useLongestLength/defaults.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_expressions.py b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_expressions.py new file mode 100644 index 000000000..0e65c949a --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/array/zip/test_zip_expressions.py @@ -0,0 +1,239 @@ +""" +Expression and field path tests for $zip expression. + +Tests field path lookups, composite paths, system variables, +and null/missing propagation via expressions. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.error_codes import ZIP_REQUIRES_ARRAY_ELEMENT_ERROR +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Field Path Resolution]: $zip resolves nested and composite field paths. +# Property [Field Lookup]: $zip resolves field paths in expressions. +FIELD_LOOKUP_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_field_path", + expression={"$zip": {"inputs": ["$a.b", "$a.c"]}}, + doc={"a": {"b": [1, 2], "c": [3, 4]}}, + expected=[[1, 3], [2, 4]], + msg="$zip should resolve nested field paths", + ), + ExpressionTestCase( + "deeply_nested_field", + expression={"$zip": {"inputs": ["$a.b.c", "$a.b.d"]}}, + doc={"a": {"b": {"c": [10], "d": [20]}}}, + expected=[[10, 20]], + msg="$zip should resolve deeply nested field paths", + ), + ExpressionTestCase( + "nonexistent_field_null", + expression={"$zip": {"inputs": ["$a.nonexistent", "$b"]}}, + doc={"a": {"missing": 1}, "b": [1]}, + expected=None, + msg="$zip non-existent field should propagate null", + ), + ExpressionTestCase( + "same_field_twice", + expression={"$zip": {"inputs": ["$arr", "$arr"]}}, + doc={"arr": [1, 2, 3]}, + expected=[[1, 1], [2, 2], [3, 3]], + msg="$zip same field used twice should zip with itself", + ), +] + +# Property [Composite Paths]: $zip resolves composite array paths from array-of-objects. +COMPOSITE_PATH_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "composite_array", + expression={"$zip": {"inputs": ["$x.y", "$x.z"]}}, + doc={"x": [{"y": 1, "z": 10}, {"y": 2, "z": 20}]}, + expected=[[1, 10], [2, 20]], + msg="$zip composite array path from array-of-objects", + ), + ExpressionTestCase( + "array_index_path_resolves_empty", + expression={"$zip": {"inputs": ["$a.0", [1, 2]]}}, + doc={"a": [[1, 2], [3, 4]]}, + expected=[], + msg="$zip array index path resolves to [] (shortest)", + ), +] + +# Property [Variables]: $zip works with $let and system variables. +LET_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "let_variable", + expression={ + "$let": { + "vars": {"a": "$arr1", "b": "$arr2"}, + "in": {"$zip": {"inputs": ["$$a", "$$b"]}}, + } + }, + doc={"arr1": [1, 2], "arr2": [3, 4]}, + expected=[[1, 3], [2, 4]], + msg="$zip should work with $let variables", + ), + ExpressionTestCase( + "root_variable", + expression={"$zip": {"inputs": ["$$ROOT.a", "$$ROOT.b"]}}, + doc={"_id": 1, "a": [1], "b": [2]}, + expected=[[1, 2]], + msg="$zip should work with $$ROOT", + ), + ExpressionTestCase( + "current_variable", + expression={"$zip": {"inputs": ["$$CURRENT.a", "$$CURRENT.b"]}}, + doc={"_id": 2, "a": [1], "b": [2]}, + expected=[[1, 2]], + msg="$$CURRENT should be equivalent to field path", + ), +] + +# Property [Null/Missing Fields]: $zip handles null and missing field paths. +NULL_MISSING_EXPR_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "missing_field", + expression={"$zip": {"inputs": ["$nonexistent", [1]]}}, + doc={"other": 1}, + expected=None, + msg="$zip missing field should propagate null", + ), + ExpressionTestCase( + "missing_input_type_is_null", + expression={"$type": {"$zip": {"inputs": ["$nonexistent", [1]]}}}, + doc={"x": 1}, + expected="null", + msg="$zip missing field should produce null type", + ), + ExpressionTestCase( + "remove_variable", + expression={"$zip": {"inputs": ["$$REMOVE", [1]]}}, + doc={"x": 1}, + expected=None, + msg="$$REMOVE propagates null", + ), + ExpressionTestCase( + "field_ref_non_array", + expression={"$zip": {"inputs": ["$a", [1]]}}, + doc={"a": 1}, + error_code=ZIP_REQUIRES_ARRAY_ELEMENT_ERROR, + msg="$zip field resolving to non-array should error", + ), + ExpressionTestCase( + "missing_first_input", + expression={"$zip": {"inputs": [MISSING, [1, 2]]}}, + doc={"a": 1}, + expected=None, + msg="$zip missing first input returns null", + ), + ExpressionTestCase( + "all_missing_fields", + expression={"$zip": {"inputs": ["$x", "$y"]}}, + doc={"_placeholder": 1}, + expected=None, + msg="$zip all missing fields return null", + ), + ExpressionTestCase( + "explicit_null_field", + expression={"$zip": {"inputs": ["$a", "$b"]}}, + doc={"a": None, "b": [1, 2]}, + expected=None, + msg="$zip explicit null field returns null", + ), + ExpressionTestCase( + "null_with_longest_true", + expression={"$zip": {"inputs": ["$a", [1, 2]], "useLongestLength": True}}, + doc={"a": None}, + expected=None, + msg="$zip null input with longest true returns null", + ), + ExpressionTestCase( + "null_with_defaults", + expression={ + "$zip": {"inputs": ["$a", [1, 2]], "useLongestLength": True, "defaults": [0, 0]} + }, + doc={"a": None}, + expected=None, + msg="$zip null input with defaults still returns null", + ), + ExpressionTestCase( + "missing_field_as_element", + expression={"$zip": {"inputs": [["$not_exist", 2], [1, 2]]}}, + doc={"a": 1}, + expected=[[None, 1], [2, 2]], + msg="$zip missing field as element becomes null", + ), +] + +# Property [Self-Composition]: $zip composes with nested $zip calls. +SELF_COMPOSITION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "nested_zip_full", + expression={ + "$zip": { + "inputs": [ + {"$zip": {"inputs": ["$a", "$b"]}}, + "$c", + ] + } + }, + doc={"a": [1, 2], "b": [3, 4], "c": ["x", "y"]}, + expected=[[[1, 3], "x"], [[2, 4], "y"]], + msg="$zip nested $zip as input works correctly", + ), +] + +# Property [Field Path Elements]: $zip resolves field paths used as array elements. +FIELD_PATH_ELEMENT_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "field_path_as_element", + expression={"$zip": {"inputs": [["$a", "$b"], [1, 2]]}}, + doc={"a": 10, "b": 20}, + expected=[[10, 1], [20, 2]], + msg="$zip field paths as elements resolve correctly", + ), + ExpressionTestCase( + "nested_field_path_as_element", + expression={"$zip": {"inputs": [["$foo.bar", "$b"], [1, 2]]}}, + doc={"foo": {"bar": 10}, "b": 20}, + expected=[[10, 1], [20, 2]], + msg="$zip nested field path as element resolves correctly", + ), + ExpressionTestCase( + "field_path_in_defaults", + expression={ + "$zip": {"inputs": ["$a", "$b"], "useLongestLength": True, "defaults": ["$c", "$d"]} + }, + doc={"a": [1, 2, 3], "b": ["x"], "c": "default_a", "d": "default_b"}, + expected=[[1, "x"], [2, "default_b"], [3, "default_b"]], + msg="$zip field paths in defaults resolve from document", + ), +] + +ALL_EXPR_TESTS = ( + FIELD_LOOKUP_TESTS + + COMPOSITE_PATH_TESTS + + LET_TESTS + + NULL_MISSING_EXPR_TESTS + + SELF_COMPOSITION_TESTS + + FIELD_PATH_ELEMENT_TESTS +) + + +@pytest.mark.parametrize("test", pytest_params(ALL_EXPR_TESTS)) +def test_zip_field_paths_and_variables(collection, test): + """Test $zip with field paths, $let, system variables, and null propagation.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_map.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_map.py new file mode 100644 index 000000000..465c12f31 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_map.py @@ -0,0 +1,96 @@ +""" +Combination tests for $map composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +MAP_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "map_on_range", + expression={ + "$map": {"input": {"$range": [0, 5]}, "in": {"$multiply": ["$$this", "$$this"]}} + }, + doc={"_placeholder": 1}, + expected=[0, 1, 4, 9, 16], + msg="$map should map squares over $range result", + ), + ExpressionTestCase( + "map_with_concatArrays", + expression={ + "$concatArrays": [ + {"$map": {"input": "$a", "in": {"$multiply": ["$$this", 2]}}}, + {"$map": {"input": "$b", "in": {"$multiply": ["$$this", 3]}}}, + ] + }, + doc={"a": [1, 2], "b": [3, 4]}, + expected=[2, 4, 9, 12], + msg="$map should concatenate two mapped arrays", + ), + ExpressionTestCase( + "map_with_filter", + expression={ + "$map": { + "input": {"$filter": {"input": "$arr", "cond": {"$gt": ["$$this", 2]}}}, + "in": {"$multiply": ["$$this", 10]}, + } + }, + doc={"arr": [1, 2, 3, 4, 5]}, + expected=[30, 40, 50], + msg="$map should map over filtered array", + ), + ExpressionTestCase( + "map_result_into_reduce", + expression={ + "$reduce": { + "input": {"$map": {"input": "$arr", "in": {"$multiply": ["$$this", 2]}}}, + "initialValue": 0, + "in": {"$add": ["$$value", "$$this"]}, + } + }, + doc={"arr": [1, 2, 3]}, + expected=12, + msg="$reduce on $map result should sum doubled values", + ), + ExpressionTestCase( + "map_3_level_nested", + expression={ + "$map": { + "input": [[[1]]], + "as": "a", + "in": { + "$map": { + "input": "$$a", + "as": "b", + "in": { + "$map": { + "input": "$$b", + "in": {"$add": ["$$this", 100]}, + } + }, + } + }, + } + }, + doc={"_placeholder": 1}, + expected=[[[101]]], + msg="$map 3-level nested $map should work", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(MAP_COMBINATION_TESTS)) +def test_map_combination(collection, test): + """Test $map composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_range.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_range.py new file mode 100644 index 000000000..8ad13a7ff --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_range.py @@ -0,0 +1,89 @@ +""" +Combination tests for $range composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params + +RANGE_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "reverseArray_on_range", + expression={"$reverseArray": {"$range": ["$start", "$end"]}}, + doc={"start": 0, "end": 5}, + expected=[4, 3, 2, 1, 0], + msg="$reverseArray on $range result", + ), + ExpressionTestCase( + "concatArrays_two_ranges", + expression={"$concatArrays": [{"$range": [0, 3]}, {"$range": [3, 6]}]}, + doc={"x": 1}, + expected=[0, 1, 2, 3, 4, 5], + msg="$concatArrays on two $range results", + ), + ExpressionTestCase( + "in_on_range", + expression={"$in": [5, {"$range": ["$start", "$end"]}]}, + doc={"start": 0, "end": 10}, + expected=True, + msg="$in on $range result", + ), + ExpressionTestCase( + "isArray_on_range", + expression={"$isArray": {"$range": [0, 3]}}, + doc={"x": 1}, + expected=True, + msg="$isArray on $range result should return true", + ), + ExpressionTestCase( + "in_miss_on_range", + expression={"$in": [5, {"$range": [0, 5]}]}, + doc={"x": 1}, + expected=False, + msg="$range 5 should not be in [0..4] (exclusive end)", + ), + ExpressionTestCase( + "self_nesting_start", + expression={"$range": [{"$arrayElemAt": [{"$range": [2, 5]}, 0]}, 10]}, + doc={"x": 1}, + expected=[2, 3, 4, 5, 6, 7, 8, 9], + msg="$range start from inner range", + ), + ExpressionTestCase( + "self_nesting_end", + expression={"$range": [0, {"$size": {"$range": [0, 5]}}]}, + doc={"x": 1}, + expected=[0, 1, 2, 3, 4], + msg="$range end from size of inner range", + ), + ExpressionTestCase( + "indexOfArray_on_range", + expression={"$indexOfArray": [{"$range": [0, 10]}, 7]}, + doc={"x": 1}, + expected=7, + msg="$range index of 7 in range 0..9 should be 7", + ), + ExpressionTestCase( + "output_type_is_int", + expression={"$type": {"$arrayElemAt": [{"$range": [0, 1]}, 0]}}, + doc={"x": 1}, + expected="int", + msg="$range output element should be int type", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(RANGE_COMBINATION_TESTS)) +def test_range_combination(collection, test): + """Test $range composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_zip.py b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_zip.py new file mode 100644 index 000000000..0ac70e1e5 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/test_expressions_combination_zip.py @@ -0,0 +1,126 @@ +""" +Combination tests for $zip composed with other operators. +""" + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.expression_test_case import ( # noqa: E501 + ExpressionTestCase, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( # noqa: E501 + assert_expression_result, + execute_expression_with_insert, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import FLOAT_NAN + +ZIP_COMBINATION_TESTS: list[ExpressionTestCase] = [ + ExpressionTestCase( + "zip_on_sortArray", + expression={"$zip": {"inputs": [{"$sortArray": {"input": "$a", "sortBy": 1}}, "$b"]}}, + doc={"a": [3, 1, 2], "b": [10, 20, 30]}, + expected=[[1, 10], [2, 20], [3, 30]], + msg="$zip should zip $sortArray result with array", + ), + ExpressionTestCase( + "zip_on_concatArrays", + expression={"$zip": {"inputs": [{"$concatArrays": ["$a", "$b"]}, "$c"]}}, + doc={"a": [1], "b": [2], "c": [10, 20]}, + expected=[[1, 10], [2, 20]], + msg="$zip should zip $concatArrays result with array", + ), + ExpressionTestCase( + "zip_on_range", + expression={"$zip": {"inputs": [{"$range": [0, 3]}, {"$range": [10, 13]}]}}, + doc={"x": 1}, + expected=[[0, 10], [1, 11], [2, 12]], + msg="$zip should zip two $range results", + ), + ExpressionTestCase( + "arrayElemAt_on_zip", + expression={"$arrayElemAt": [{"$zip": {"inputs": ["$a", "$b"]}}, 1]}, + doc={"a": [1, 2, 3], "b": [10, 20, 30]}, + expected=[2, 20], + msg="$arrayElemAt on $zip result", + ), + ExpressionTestCase( + "zip_matrix_transpose_2x3", + expression={ + "$zip": { + "inputs": [ + {"$arrayElemAt": ["$matrix", 0]}, + {"$arrayElemAt": ["$matrix", 1]}, + ] + } + }, + doc={"matrix": [[0, 1, 2], [3, 4, 5]]}, + expected=[[0, 3], [1, 4], [2, 5]], + msg="$zip 2x3 matrix transposed to 3x2", + ), + ExpressionTestCase( + "zip_index_preservation", + expression={"$zip": {"inputs": ["$arr", {"$range": [0, {"$size": "$arr"}]}]}}, + doc={"arr": ["a", "b", "c"]}, + expected=[["a", 0], ["b", 1], ["c", 2]], + msg="$zip elements paired with indices", + ), + ExpressionTestCase( + "zip_reduce_on_output", + expression={ + "$reduce": { + "input": {"$zip": {"inputs": ["$a", "$b"]}}, + "initialValue": 0, + "in": {"$add": ["$$value", {"$arrayElemAt": ["$$this", 1]}]}, + } + }, + doc={"a": [1, 2, 3], "b": [10, 20, 30]}, + expected=60, + msg="$reduce sums second elements of zipped pairs", + ), + ExpressionTestCase( + "zip_output_is_array", + expression={"$isArray": {"$zip": {"inputs": ["$a", "$b"]}}}, + doc={"a": [1, 2], "b": ["a", "b"]}, + expected=True, + msg="$zip output is an array", + ), + ExpressionTestCase( + "float_nan_preserved", + expression={"$arrayElemAt": [{"$arrayElemAt": [{"$zip": {"inputs": ["$a", "$b"]}}, 0]}, 0]}, + doc={"a": [FLOAT_NAN], "b": [1]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$zip float NaN element preserved after zipping", + ), + ExpressionTestCase( + "default_float_nan", + expression={ + "$arrayElemAt": [ + { + "$arrayElemAt": [ + { + "$zip": { + "inputs": ["$a", "$b"], + "useLongestLength": True, + "defaults": [0, FLOAT_NAN], + } + }, + 1, + ] + }, + 1, + ] + }, + doc={"a": [1, 2], "b": [10]}, + expected=pytest.approx(FLOAT_NAN, nan_ok=True), + msg="$zip should use float NaN as default value", + ), +] + + +@pytest.mark.parametrize("test", pytest_params(ZIP_COMBINATION_TESTS)) +def test_zip_combination(collection, test): + """Test $zip composed with other operators.""" + result = execute_expression_with_insert(collection, test.expression, test.doc) + assert_expression_result( + result, expected=test.expected, error_code=test.error_code, msg=test.msg + )